diff --git a/lib/puppet/forge.rb b/lib/puppet/forge.rb index b985f0fa8..d43114b80 100644 --- a/lib/puppet/forge.rb +++ b/lib/puppet/forge.rb @@ -1,202 +1,199 @@ require 'puppet/vendor' Puppet::Vendor.load_vendored require 'net/http' require 'tempfile' require 'uri' require 'pathname' require 'json' require 'semantic' class Puppet::Forge < Semantic::Dependency::Source require 'puppet/forge/cache' require 'puppet/forge/repository' require 'puppet/forge/errors' include Puppet::Forge::Errors USER_AGENT = "PMT/1.1.1 (v3; Net::HTTP)".freeze attr_reader :host, :repository def initialize(host = Puppet[:module_repository]) @host = host @repository = Puppet::Forge::Repository.new(host, USER_AGENT) end # Return a list of module metadata hashes that match the search query. # This return value is used by the module_tool face install search, # and displayed to on the console. # # Example return value: # # [ # { # "author" => "puppetlabs", # "name" => "bacula", # "tag_list" => ["backup", "bacula"], # "releases" => [{"version"=>"0.0.1"}, {"version"=>"0.0.2"}], # "full_name" => "puppetlabs/bacula", # "version" => "0.0.2", # "project_url" => "http://github.com/puppetlabs/puppetlabs-bacula", # "desc" => "bacula" # } # ] # # @param term [String] search term # @return [Array] modules found # @raise [Puppet::Forge::Errors::CommunicationError] if there is a network # related error # @raise [Puppet::Forge::Errors::SSLVerifyError] if there is a problem # verifying the remote SSL certificate # @raise [Puppet::Forge::Errors::ResponseError] if the repository returns a # bad HTTP response def search(term) matches = [] uri = "/v3/modules?query=#{URI.escape(term)}" while uri response = make_http_request(uri) if response.code == '200' result = JSON.parse(response.body) uri = result['pagination']['next'] matches.concat result['results'] else raise ResponseError.new(:uri => URI.parse(@host).merge(uri) , :input => term, :response => response) end end matches.each do |mod| mod['author'] = mod['owner']['username'] mod['tag_list'] = mod['current_release']['tags'] mod['full_name'] = "#{mod['author']}/#{mod['name']}" mod['version'] = mod['current_release']['version'] mod['project_url'] = mod['homepage_url'] mod['desc'] = mod['current_release']['metadata']['summary'] || '' end end # Fetches {ModuleRelease} entries for each release of the named module. # # @param input [String] the module name to look up # @return [Array] a list of releases for # the given name # @see Semantic::Dependency::Source#fetch def fetch(input) name = input.tr('/', '-') uri = "/v3/releases?module=#{name}" releases = [] while uri response = make_http_request(uri) if response.code == '200' response = JSON.parse(response.body) else raise ResponseError.new(:uri => URI.parse(@host).merge(uri), :input => input, :response => response) end releases.concat(process(response['results'])) uri = response['pagination']['next'] end return releases end def make_http_request(*args) @repository.make_http_request(*args) end class ModuleRelease < Semantic::Dependency::ModuleRelease attr_reader :install_dir, :metadata def initialize(source, data) @data = data @metadata = meta = data['metadata'] name = meta['name'].tr('/', '-') version = Semantic::Version.parse(meta['version']) + release = "#{name}@#{version}" dependencies = (meta['dependencies'] || []) dependencies.map! do |dep| - range = dep['version_requirement'] || dep['versionRequirement'] || '>=0' - [ - dep['name'].tr('/', '-'), - (Semantic::VersionRange.parse(range) rescue Semantic::VersionRange::EMPTY_RANGE), - ] + Puppet::ModuleTool.parse_module_dependency(release, dep)[0..1] end super(source, name, version, Hash[dependencies]) end def install(dir) staging_dir = self.prepare module_dir = dir + name[/-(.*)/, 1] module_dir.rmtree if module_dir.exist? # Make sure unpacked module has the same ownership as the folder we are moving it into. Puppet::ModuleTool::Applications::Unpacker.harmonize_ownership(dir, staging_dir) FileUtils.mv(staging_dir, module_dir) @install_dir = dir # Return the Pathname object representing the directory where the # module release archive was unpacked the to. return module_dir ensure staging_dir.rmtree if staging_dir.exist? end def prepare return @unpacked_into if @unpacked_into download(@data['file_uri'], tmpfile) validate_checksum(tmpfile, @data['file_md5']) unpack(tmpfile, tmpdir) @unpacked_into = Pathname.new(tmpdir) end private # Obtain a suitable temporary path for unpacking tarballs # # @return [Pathname] path to temporary unpacking location def tmpdir @dir ||= Dir.mktmpdir(name, Puppet::Forge::Cache.base_path) end def tmpfile @file ||= Tempfile.new(name, Puppet::Forge::Cache.base_path).tap do |f| f.binmode end end def download(uri, destination) @source.make_http_request(uri, destination) destination.flush and destination.close end def validate_checksum(file, checksum) if Digest::MD5.file(file.path).hexdigest != checksum raise RuntimeError, "Downloaded release for #{name} did not match expected checksum" end end def unpack(file, destination) begin Puppet::ModuleTool::Applications::Unpacker.unpack(file.path, destination) rescue Puppet::ExecutionFailure => e raise RuntimeError, "Could not extract contents of module archive: #{e.message}" end end end private def process(list) list.map { |release| ModuleRelease.new(self, release) } end end diff --git a/lib/puppet/module_tool.rb b/lib/puppet/module_tool.rb index 690aa2f2d..8f462f6d8 100644 --- a/lib/puppet/module_tool.rb +++ b/lib/puppet/module_tool.rb @@ -1,171 +1,194 @@ # encoding: UTF-8 # Load standard libraries require 'pathname' require 'fileutils' require 'puppet/util/colors' module Puppet module ModuleTool require 'puppet/module_tool/tar' extend Puppet::Util::Colors # Directory and names that should not be checksummed. ARTIFACTS = ['pkg', /^\./, /^~/, /^#/, 'coverage', 'checksums.json', 'REVISION'] FULL_MODULE_NAME_PATTERN = /\A([^-\/|.]+)[-|\/](.+)\z/ REPOSITORY_URL = Puppet.settings[:module_repository] # Is this a directory that shouldn't be checksummed? # # TODO: Should this be part of Checksums? # TODO: Rename this method to reflect its purpose? # TODO: Shouldn't this be used when building packages too? def self.artifact?(path) case File.basename(path) when *ARTIFACTS true else false end end # Return the +username+ and +modname+ for a given +full_module_name+, or raise an # ArgumentError if the argument isn't parseable. def self.username_and_modname_from(full_module_name) if matcher = full_module_name.match(FULL_MODULE_NAME_PATTERN) return matcher.captures else raise ArgumentError, "Not a valid full name: #{full_module_name}" end end # Find the module root when given a path by checking each directory up from # its current location until it finds one that contains a file called # 'Modulefile'. # # @param path [Pathname, String] path to start from # @return [Pathname, nil] the root path of the module directory or nil if # we cannot find one def self.find_module_root(path) path = Pathname.new(path) if path.class == String path.expand_path.ascend do |p| return p if is_module_root?(p) end nil end # Analyse path to see if it is a module root directory by detecting a # file named 'metadata.json' or 'Modulefile' in the directory. # # @param path [Pathname, String] path to analyse # @return [Boolean] true if the path is a module root, false otherwise def self.is_module_root?(path) path = Pathname.new(path) if path.class == String FileTest.file?(path + 'metadata.json') || FileTest.file?(path + 'Modulefile') end # Builds a formatted tree from a list of node hashes containing +:text+ # and +:dependencies+ keys. def self.format_tree(nodes, level = 0) str = '' nodes.each_with_index do |node, i| last_node = nodes.length - 1 == i deps = node[:dependencies] || [] str << (indent = " " * level) str << (last_node ? "└" : "├") str << "─" str << (deps.empty? ? "─" : "┬") str << " #{node[:text]}\n" branch = format_tree(deps, level + 1) branch.gsub!(/^#{indent} /, indent + '│') unless last_node str << branch end return str end def self.build_tree(mods, dir) mods.each do |mod| version_string = mod[:version].to_s.sub(/^(?!v)/, 'v') if mod[:action] == :upgrade previous_version = mod[:previous_version].to_s.sub(/^(?!v)/, 'v') version_string = "#{previous_version} -> #{version_string}" end mod[:text] = "#{mod[:name]} (#{colorize(:cyan, version_string)})" mod[:text] += " [#{mod[:path]}]" unless mod[:path].to_s == dir.to_s deps = (mod[:dependencies] || []) deps.sort! { |a, b| a[:name] <=> b[:name] } build_tree(deps, dir) end end # @param options [Hash] This hash will contain any # command-line arguments that are not Settings, as those will have already # been extracted by the underlying application code. # # @note Unfortunately the whole point of this method is the side effect of # modifying the options parameter. This same hash is referenced both # when_invoked and when_rendering. For this reason, we are not returning # a duplicate. # @todo Validate the above note... # # An :environment_instance and a :target_dir are added/updated in the # options parameter. # # @api private def self.set_option_defaults(options) current_environment = environment_from_options(options) modulepath = [options[:target_dir]] + current_environment.full_modulepath face_environment = current_environment.override_with(:modulepath => modulepath.compact) options[:environment_instance] = face_environment # Note: environment will have expanded the path options[:target_dir] = face_environment.full_modulepath.first end # Given a hash of options, we should discover or create a # {Puppet::Node::Environment} instance that reflects the provided options. # # Generally speaking, the `:modulepath` parameter should supercede all # others, the `:environment` parameter should follow after that, and we # should default to Puppet's current environment. # # @param options [{Symbol => Object}] the options to derive environment from # @return [Puppet::Node::Environment] the environment described by the options def self.environment_from_options(options) if options[:modulepath] path = options[:modulepath].split(File::PATH_SEPARATOR) Puppet::Node::Environment.create(:anonymous, path, '') elsif options[:environment].is_a?(Puppet::Node::Environment) options[:environment] elsif options[:environment] # This use of looking up an environment is correct since it honours # a reguest to get a particular environment via environment name. Puppet.lookup(:environments).get(options[:environment]) else Puppet.lookup(:current_environment) end end + + # Handles parsing of module dependency expressions into proper + # {Semantic::VersionRange}s, including reasonable error handling. + # + # @param where [String] a description of the thing we're parsing the + # dependency expression for + # @param dep [Hash] the dependency description to parse + # @return [Array(String, Semantic::VersionRange, String)] an tuple of the + # dependent module's name, the version range dependency, and the + # unparsed range expression. + def self.parse_module_dependency(where, dep) + dep_name = dep['name'].tr('/', '-') + range = dep['version_requirement'] || dep['versionRequirement'] || '>= 0.0.0' + + begin + parsed_range = Semantic::VersionRange.parse(range) + rescue ArgumentError => e + Puppet.debug "Error in #{where} parsing dependency #{dep_name} (#{e.message}); using empty range." + parsed_range = Semantic::VersionRange::EMPTY_RANGE + end + + [ dep_name, parsed_range, range ] + end end end # Load remaining libraries require 'puppet/module_tool/errors' require 'puppet/module_tool/applications' require 'puppet/module_tool/checksums' require 'puppet/module_tool/contents_description' require 'puppet/module_tool/dependency' require 'puppet/module_tool/metadata' require 'puppet/module_tool/modulefile' require 'puppet/forge/cache' require 'puppet/forge' diff --git a/lib/puppet/module_tool/installed_modules.rb b/lib/puppet/module_tool/installed_modules.rb index da27a0404..0e82b6bd0 100644 --- a/lib/puppet/module_tool/installed_modules.rb +++ b/lib/puppet/module_tool/installed_modules.rb @@ -1,92 +1,93 @@ require 'pathname' require 'puppet/forge' require 'puppet/module_tool' module Puppet::ModuleTool class InstalledModules < Semantic::Dependency::Source attr_reader :modules, :by_name def priority 10 end def initialize(env) @env = env modules = env.modules_by_path @fetched = [] @modules = {} @by_name = {} env.modulepath.each do |path| modules[path].each do |mod| @by_name[mod.name] = mod next unless mod.has_metadata? release = ModuleRelease.new(self, mod) @modules[release.name] ||= release end end @modules.freeze end # Fetches {ModuleRelease} entries for each release of the named module. # # @param name [String] the module name to look up # @return [Array] a list of releases for # the given name # @see Semantic::Dependency::Source#fetch def fetch(name) name = name.tr('/', '-') if @modules.key? name @fetched << name [ @modules[name] ] else [ ] end end def fetched @fetched end class ModuleRelease < Semantic::Dependency::ModuleRelease attr_reader :mod, :metadata def initialize(source, mod) @mod = mod @metadata = mod.metadata name = mod.forge_name.tr('/', '-') version = Semantic::Version.parse(mod.version) + release = "#{name}@#{version}" super(source, name, version, {}) if mod.dependencies mod.dependencies.each do |dep| - range = dep['version_requirement'] || dep['versionRequirement'] || '>=0' - range = Semantic::VersionRange.parse(range) rescue Semantic::VersionRange::EMPTY_RANGE + results = Puppet::ModuleTool.parse_module_dependency(release, dep) + dep_name, parsed_range, range = results dep.tap do |dep| - add_constraint('initialize', dep['name'].tr('/', '-'), range.to_s) do |node| - range === node.version + add_constraint('initialize', dep_name, range.to_s) do |node| + parsed_range === node.version end end end end end def install_dir Pathname.new(@mod.path).dirname end def install(dir) # If we're already installed, there's no need for us to faff about. end def prepare # We're already installed; what preparation remains? end end end end diff --git a/lib/puppet/module_tool/local_tarball.rb b/lib/puppet/module_tool/local_tarball.rb index b366df3a1..d85c4c0fa 100644 --- a/lib/puppet/module_tool/local_tarball.rb +++ b/lib/puppet/module_tool/local_tarball.rb @@ -1,91 +1,90 @@ require 'pathname' require 'tmpdir' require 'puppet/forge' require 'puppet/module_tool' module Puppet::ModuleTool class LocalTarball < Semantic::Dependency::Source attr_accessor :release def initialize(filename) unpack(filename, tmpdir) Puppet.debug "Unpacked local tarball to #{tmpdir}" mod = Puppet::Module.new('tarball', tmpdir, nil) @release = ModuleRelease.new(self, mod) end def fetch(name) if @release.name == name [ @release ] else [ ] end end def prepare(release) release.mod.path end def install(release, dir) staging_dir = release.prepare module_dir = dir + release.name[/-(.*)/, 1] module_dir.rmtree if module_dir.exist? # Make sure unpacked module has the same ownership as the folder we are moving it into. Puppet::ModuleTool::Applications::Unpacker.harmonize_ownership(dir, staging_dir) FileUtils.mv(staging_dir, module_dir) end class ModuleRelease < Semantic::Dependency::ModuleRelease attr_reader :mod, :install_dir, :metadata def initialize(source, mod) @mod = mod @metadata = mod.metadata name = mod.forge_name.tr('/', '-') version = Semantic::Version.parse(mod.version) + release = "#{name}@#{version}" if mod.dependencies dependencies = mod.dependencies.map do |dep| - range = dep['version_requirement'] || dep['versionRequirement'] || '>=0' - range = Semantic::VersionRange.parse(range) rescue Semantic::VersionRange::EMPTY_RANGE - [ dep['name'].tr('/', '-'), range ] + Puppet::ModuleTool.parse_module_dependency(release, dep)[0..1] end dependencies = Hash[dependencies] end super(source, name, version, dependencies || {}) end def install(dir) @source.install(self, dir) @install_dir = dir end def prepare @source.prepare(self) end end private # Obtain a suitable temporary path for unpacking tarballs # # @return [String] path to temporary unpacking location def tmpdir @dir ||= Dir.mktmpdir('local-tarball', Puppet::Forge::Cache.base_path) end def unpack(file, destination) begin Puppet::ModuleTool::Applications::Unpacker.unpack(file, destination) rescue Puppet::ExecutionFailure => e raise RuntimeError, "Could not extract contents of module archive: #{e.message}" end end end end diff --git a/lib/puppet/module_tool/tar/gnu.rb b/lib/puppet/module_tool/tar/gnu.rb index 61a6d2348..f0fd2af5b 100644 --- a/lib/puppet/module_tool/tar/gnu.rb +++ b/lib/puppet/module_tool/tar/gnu.rb @@ -1,28 +1,19 @@ +require 'shellwords' + class Puppet::ModuleTool::Tar::Gnu def unpack(sourcefile, destdir, owner) sourcefile = File.expand_path(sourcefile) destdir = File.expand_path(destdir) Dir.chdir(destdir) do - tarball = Puppet::Util::Execution.execute(['gzip', '-dc', sourcefile]) - Puppet::Util::Execution.execpipe(['tar', 'xof', '-'], true, 'w+') do |pipe| - pipe.write(tarball) - end - - Puppet::Util::Execution.execute(['find', destdir] + %w[-type d -exec chmod 755 {} +]) - Puppet::Util::Execution.execute(['find', destdir] + %w[-type f -exec chmod a-wst {} +]) - Puppet::Util::Execution.execute(['chown', '-R', owner, destdir]) + Puppet::Util::Execution.execute("gzip -dc #{Shellwords.shellescape(sourcefile)} | tar xof -") + Puppet::Util::Execution.execute("find . -type d -exec chmod 755 {} +") + Puppet::Util::Execution.execute("find . -type f -exec chmod a-wst {} +") + Puppet::Util::Execution.execute("chown -R #{owner} .") end end def pack(sourcedir, destfile) - tarball = Puppet::Util::Execution.execute(['tar', 'cf', '-', sourcedir]) - Puppet::Util::Execution.execpipe(['gzip', '-c'], true, 'w+') do |pipe| - pipe.write(tarball) - pipe.close_write - File.open(destfile, 'w+') do |file| - file.write(pipe.read) - end - end + Puppet::Util::Execution.execute("tar cf - #{sourcedir} | gzip -c > #{File.basename(destfile)}") end end diff --git a/lib/puppet/util/execution.rb b/lib/puppet/util/execution.rb index bb2a247a8..fb03510a9 100644 --- a/lib/puppet/util/execution.rb +++ b/lib/puppet/util/execution.rb @@ -1,317 +1,315 @@ module Puppet require 'rbconfig' require 'puppet/error' # A command failed to execute. # @api public class ExecutionFailure < Puppet::Error end end # This module defines methods for execution of system commands. It is intented for inclusion # in classes that needs to execute system commands. # @api public module Puppet::Util::Execution # This is the full output from a process. The object itself (a String) is the # stdout of the process. # # @api public class ProcessOutput < String # @return [Integer] The exit status of the process # @api public attr_reader :exitstatus # @api private def initialize(value,exitstatus) super(value) @exitstatus = exitstatus end end # The command can be a simple string, which is executed as-is, or an Array, # which is treated as a set of command arguments to pass through. # # In either case, the command is passed directly to the shell, STDOUT and - # STDERR are connected together, and STDOUT and STDIN are available via the - # yielded pipe. (Bear in mind that reading from or writing to a pipe that has - # not been opened in read or write mode respectively will block indefinitely.) + # STDERR are connected together, and STDOUT will be streamed to the yielded + # pipe. # # @param command [String, Array] the command to execute as one string, # or as parts in an array. The parts of the array are joined with one # separating space between each entry when converting to the command line # string to execute. # @param failonfail [Boolean] (true) if the execution should fail with # Exception on failure or not. - # @param mode [String] ('r') the mode to open the pipe with # @yield [pipe] to a block executing a subprocess # @yieldparam pipe [IO] the opened pipe # @yieldreturn [String] the output to return # @raise [Puppet::ExecutionFailure] if the executed chiled process did not # exit with status == 0 and `failonfail` is `true`. # @return [String] a string with the output from the subprocess executed by # the given block # # @see Kernel#open for `mode` values # @api public - def self.execpipe(command, failonfail = true, mode = 'r') + def self.execpipe(command, failonfail = true) # Paste together an array with spaces. We used to paste directly # together, no spaces, which made for odd invocations; the user had to # include whitespace between arguments. # # Having two spaces is really not a big drama, since this passes to the # shell anyhow, while no spaces makes for a small developer cost every # time this is invoked. --daniel 2012-02-13 command_str = command.respond_to?(:join) ? command.join(' ') : command if respond_to? :debug debug "Executing '#{command_str}'" else Puppet.debug "Executing '#{command_str}'" end # force the run of the command with # the user/system locale to "C" (via environment variables LANG and LC_*) # it enables to have non localized output for some commands and therefore # a predictable output english_env = ENV.to_hash.merge( {'LANG' => 'C', 'LC_ALL' => 'C'} ) output = Puppet::Util.withenv(english_env) do - open("| #{command_str} 2>&1", mode) do |pipe| + open("| #{command_str} 2>&1") do |pipe| yield pipe end end if failonfail unless $CHILD_STATUS == 0 raise Puppet::ExecutionFailure, output end end output end # Wraps execution of {execute} with mapping of exception to given exception (and output as argument). # @raise [exception] under same conditions as {execute}, but raises the given `exception` with the output as argument # @return (see execute) # @api public def self.execfail(command, exception) output = execute(command) return output rescue Puppet::ExecutionFailure raise exception, output, exception.backtrace end # Default empty options for {execute} NoOptionsSpecified = {} # Executes the desired command, and return the status and output. # def execute(command, options) # @param command [Array, String] the command to execute. If it is # an Array the first element should be the executable and the rest of the # elements should be the individual arguments to that executable. # @param options [Hash] a Hash of options # @option options [Boolean] :failonfail if this value is set to true, then this method will raise an error if the # command is not executed successfully. # @option options [Integer, String] :uid (nil) the user id of the user that the process should be run as # @option options [Integer, String] :gid (nil) the group id of the group that the process should be run as # @option options [Boolean] :combine sets whether or not to combine stdout/stderr in the output # @option options [String] :stdinfile (nil) sets a file that can be used for stdin. Passing a string for stdin is not currently # supported. # @option options [Boolean] :squelch (true) if true, ignore stdout / stderr completely. # @option options [Boolean] :override_locale (true) by default (and if this option is set to true), we will temporarily override # the user/system locale to "C" (via environment variables LANG and LC_*) while we are executing the command. # This ensures that the output of the command will be formatted consistently, making it predictable for parsing. # Passing in a value of false for this option will allow the command to be executed using the user/system locale. # @option options [Hash<{String => String}>] :custom_environment ({}) a hash of key/value pairs to set as environment variables for the duration # of the command. # @return [Puppet::Util::Execution::ProcessOutput] output as specified by options # @raise [Puppet::ExecutionFailure] if the executed chiled process did not exit with status == 0 and `failonfail` is # `true`. # @note Unfortunately, the default behavior for failonfail and combine (since # 0.22.4 and 0.24.7, respectively) depend on whether options are specified # or not. If specified, then failonfail and combine default to false (even # when the options specified are neither failonfail nor combine). If no # options are specified, then failonfail and combine default to true. # @comment See commits efe9a833c and d32d7f30 # @api public # def self.execute(command, options = NoOptionsSpecified) # specifying these here rather than in the method signature to allow callers to pass in a partial # set of overrides without affecting the default values for options that they don't pass in default_options = { :failonfail => NoOptionsSpecified.equal?(options), :uid => nil, :gid => nil, :combine => NoOptionsSpecified.equal?(options), :stdinfile => nil, :squelch => false, :override_locale => true, :custom_environment => {}, } options = default_options.merge(options) if command.is_a?(Array) command = command.flatten.map(&:to_s) str = command.join(" ") elsif command.is_a?(String) str = command end if respond_to? :debug debug "Executing '#{str}'" else Puppet.debug "Executing '#{str}'" end null_file = Puppet.features.microsoft_windows? ? 'NUL' : '/dev/null' stdin = File.open(options[:stdinfile] || null_file, 'r') stdout = options[:squelch] ? File.open(null_file, 'w') : Tempfile.new('puppet') stderr = options[:combine] ? stdout : File.open(null_file, 'w') exec_args = [command, options, stdin, stdout, stderr] if execution_stub = Puppet::Util::ExecutionStub.current_value return execution_stub.call(*exec_args) elsif Puppet.features.posix? child_pid = execute_posix(*exec_args) exit_status = Process.waitpid2(child_pid).last.exitstatus elsif Puppet.features.microsoft_windows? process_info = execute_windows(*exec_args) begin exit_status = Puppet::Util::Windows::Process.wait_process(process_info.process_handle) ensure Puppet::Util::Windows::Process.CloseHandle(process_info.process_handle) Puppet::Util::Windows::Process.CloseHandle(process_info.thread_handle) end end [stdin, stdout, stderr].each {|io| io.close rescue nil} # read output in if required unless options[:squelch] output = wait_for_output(stdout) Puppet.warning "Could not get output" unless output end if options[:failonfail] and exit_status != 0 raise Puppet::ExecutionFailure, "Execution of '#{str}' returned #{exit_status}: #{output.strip}" end Puppet::Util::Execution::ProcessOutput.new(output || '', exit_status) end # Returns the path to the ruby executable (available via Config object, even if # it's not in the PATH... so this is slightly safer than just using Puppet::Util.which) # @return [String] the path to the Ruby executable # @api private # def self.ruby_path() File.join(RbConfig::CONFIG['bindir'], RbConfig::CONFIG['ruby_install_name'] + RbConfig::CONFIG['EXEEXT']). sub(/.*\s.*/m, '"\&"') end # Because some modules provide their own version of this method. class << self alias util_execute execute end # This is private method. # @comment see call to private_class_method after method definition # @api private # def self.execute_posix(command, options, stdin, stdout, stderr) child_pid = Puppet::Util.safe_posix_fork(stdin, stdout, stderr) do # We can't just call Array(command), and rely on it returning # things like ['foo'], when passed ['foo'], because # Array(command) will call command.to_a internally, which when # given a string can end up doing Very Bad Things(TM), such as # turning "/tmp/foo;\r\n /bin/echo" into ["/tmp/foo;\r\n", " /bin/echo"] command = [command].flatten Process.setsid begin Puppet::Util::SUIDManager.change_privileges(options[:uid], options[:gid], true) # if the caller has requested that we override locale environment variables, if (options[:override_locale]) then # loop over them and clear them Puppet::Util::POSIX::LOCALE_ENV_VARS.each { |name| ENV.delete(name) } # set LANG and LC_ALL to 'C' so that the command will have consistent, predictable output # it's OK to manipulate these directly rather than, e.g., via "withenv", because we are in # a forked process. ENV['LANG'] = 'C' ENV['LC_ALL'] = 'C' end # unset all of the user-related environment variables so that different methods of starting puppet # (automatic start during boot, via 'service', via /etc/init.d, etc.) won't have unexpected side # effects relating to user / home dir environment vars. # it's OK to manipulate these directly rather than, e.g., via "withenv", because we are in # a forked process. Puppet::Util::POSIX::USER_ENV_VARS.each { |name| ENV.delete(name) } options[:custom_environment] ||= {} Puppet::Util.withenv(options[:custom_environment]) do Kernel.exec(*command) end rescue => detail Puppet.log_exception(detail, "Could not execute posix command: #{detail}") exit!(1) end end child_pid end private_class_method :execute_posix # This is private method. # @comment see call to private_class_method after method definition # @api private # def self.execute_windows(command, options, stdin, stdout, stderr) command = command.map do |part| part.include?(' ') ? %Q["#{part.gsub(/"/, '\"')}"] : part end.join(" ") if command.is_a?(Array) options[:custom_environment] ||= {} Puppet::Util.withenv(options[:custom_environment]) do Puppet::Util::Windows::Process.execute(command, options, stdin, stdout, stderr) end end private_class_method :execute_windows # This is private method. # @comment see call to private_class_method after method definition # @api private # def self.wait_for_output(stdout) # Make sure the file's actually been written. This is basically a race # condition, and is probably a horrible way to handle it, but, well, oh # well. # (If this method were treated as private / inaccessible from outside of this file, we shouldn't have to worry # about a race condition because all of the places that we call this from are preceded by a call to "waitpid2", # meaning that the processes responsible for writing the file have completed before we get here.) 2.times do |try| if Puppet::FileSystem.exist?(stdout.path) stdout.open begin return stdout.read ensure stdout.close stdout.unlink end else time_to_sleep = try / 2.0 Puppet.warning "Waiting for output; will sleep #{time_to_sleep} seconds" sleep(time_to_sleep) end end nil end private_class_method :wait_for_output end diff --git a/lib/puppet/vendor/semantic/lib/semantic/dependency/source.rb b/lib/puppet/vendor/semantic/lib/semantic/dependency/source.rb index 575365160..f052281ed 100644 --- a/lib/puppet/vendor/semantic/lib/semantic/dependency/source.rb +++ b/lib/puppet/vendor/semantic/lib/semantic/dependency/source.rb @@ -1,25 +1,25 @@ require 'semantic/dependency' module Semantic module Dependency class Source def self.priority 0 end def priority self.class.priority end def create_release(name, version, dependencies = {}) version = Version.parse(version) if version.is_a? String dependencies = dependencies.inject({}) do |hash, (key, value)| - hash[key] = VersionRange.parse(value || '>= 0') + hash[key] = VersionRange.parse(value || '>= 0.0.0') hash[key] ||= VersionRange::EMPTY_RANGE hash end ModuleRelease.new(self, name, version, dependencies) end end end end diff --git a/spec/unit/module_tool/tar/gnu_spec.rb b/spec/unit/module_tool/tar/gnu_spec.rb index 5b1835f24..81c519634 100644 --- a/spec/unit/module_tool/tar/gnu_spec.rb +++ b/spec/unit/module_tool/tar/gnu_spec.rb @@ -1,25 +1,23 @@ require 'spec_helper' require 'puppet/module_tool' describe Puppet::ModuleTool::Tar::Gnu do let(:sourcefile) { '/space path/the/module.tar.gz' } let(:destdir) { '/space path/the/dest/dir' } let(:sourcedir) { '/space path/the/src/dir' } let(:destfile) { '/space path/the/dest/file.tar.gz' } it "unpacks a tar file" do Dir.expects(:chdir).with(File.expand_path(destdir)).yields(mock) - Puppet::Util::Execution.expects(:execute).with(["gzip", "-dc", File.expand_path(sourcefile)]) - Puppet::Util::Execution.expects(:execpipe).with(["tar", "xof", "-"], true, 'w+') - Puppet::Util::Execution.expects(:execute).with(["find", File.expand_path(destdir), "-type", "d", "-exec", "chmod", "755", "{}", "+"]) - Puppet::Util::Execution.expects(:execute).with(["find", File.expand_path(destdir), "-type", "f", "-exec", "chmod", "a-wst", "{}", "+"]) - Puppet::Util::Execution.expects(:execute).with(["chown", "-R", "", File.expand_path(destdir)]) + Puppet::Util::Execution.expects(:execute).with("gzip -dc #{Shellwords.shellescape(sourcefile)} | tar xof -") + Puppet::Util::Execution.expects(:execute).with("find . -type d -exec chmod 755 {} +") + Puppet::Util::Execution.expects(:execute).with("find . -type f -exec chmod a-wst {} +") + Puppet::Util::Execution.expects(:execute).with("chown -R .") subject.unpack(sourcefile, destdir, '') end it "packs a tar file" do - Puppet::Util::Execution.expects(:execute).with(["tar", "cf", "-", sourcedir]) - Puppet::Util::Execution.expects(:execpipe).with(["gzip", "-c"], true, 'w+') + Puppet::Util::Execution.expects(:execute).with("tar cf - #{sourcedir} | gzip -c > #{File.basename(destfile)}") subject.pack(sourcedir, destfile) end end diff --git a/spec/unit/module_tool_spec.rb b/spec/unit/module_tool_spec.rb index 8320cedf5..dee8e13bf 100755 --- a/spec/unit/module_tool_spec.rb +++ b/spec/unit/module_tool_spec.rb @@ -1,300 +1,330 @@ #! /usr/bin/env ruby # encoding: UTF-8 require 'spec_helper' require 'puppet/module_tool' describe Puppet::ModuleTool do describe '.is_module_root?' do it 'should return true if directory has a Modulefile file' do FileTest.expects(:file?).with(responds_with(:to_s, '/a/b/c/metadata.json')). returns(false) FileTest.expects(:file?).with(responds_with(:to_s, '/a/b/c/Modulefile')). returns(true) subject.is_module_root?(Pathname.new('/a/b/c')).should be_true end it 'should return true if directory has a metadata.json file' do FileTest.expects(:file?).with(responds_with(:to_s, '/a/b/c/metadata.json')). returns(true) subject.is_module_root?(Pathname.new('/a/b/c')).should be_true end it 'should return false if directory does not have a metadata.json or a Modulefile file' do FileTest.expects(:file?).with(responds_with(:to_s, '/a/b/c/metadata.json')). returns(false) FileTest.expects(:file?).with(responds_with(:to_s, '/a/b/c/Modulefile')). returns(false) subject.is_module_root?(Pathname.new('/a/b/c')).should be_false end end describe '.find_module_root' do let(:sample_path) { Pathname.new('/a/b/c').expand_path } it 'should return the first path as a pathname when it contains a module file' do Puppet::ModuleTool.expects(:is_module_root?).with(sample_path). returns(true) subject.find_module_root(sample_path).should == sample_path end it 'should return a parent path as a pathname when it contains a module file' do Puppet::ModuleTool.expects(:is_module_root?). with(responds_with(:to_s, File.expand_path('/a/b/c'))).returns(false) Puppet::ModuleTool.expects(:is_module_root?). with(responds_with(:to_s, File.expand_path('/a/b'))).returns(true) subject.find_module_root(sample_path).should == Pathname.new('/a/b').expand_path end it 'should return nil when no module root can be found' do Puppet::ModuleTool.expects(:is_module_root?).at_least_once.returns(false) subject.find_module_root(sample_path).should be_nil end end describe '.format_tree' do it 'should return an empty tree when given an empty list' do subject.format_tree([]).should == '' end it 'should return a shallow when given a list without dependencies' do list = [ { :text => 'first' }, { :text => 'second' }, { :text => 'third' } ] subject.format_tree(list).should == <<-TREE ├── first ├── second └── third TREE end it 'should return a deeply nested tree when given a list with deep dependencies' do list = [ { :text => 'first', :dependencies => [ { :text => 'second', :dependencies => [ { :text => 'third' } ] } ] }, ] subject.format_tree(list).should == <<-TREE └─┬ first └─┬ second └── third TREE end it 'should show connectors when deep dependencies are not on the last node of the top level' do list = [ { :text => 'first', :dependencies => [ { :text => 'second', :dependencies => [ { :text => 'third' } ] } ] }, { :text => 'fourth' } ] subject.format_tree(list).should == <<-TREE ├─┬ first │ └─┬ second │ └── third └── fourth TREE end it 'should show connectors when deep dependencies are not on the last node of any level' do list = [ { :text => 'first', :dependencies => [ { :text => 'second', :dependencies => [ { :text => 'third' } ] }, { :text => 'fourth' } ] } ] subject.format_tree(list).should == <<-TREE └─┬ first ├─┬ second │ └── third └── fourth TREE end it 'should show connectors in every case when deep dependencies are not on the last node' do list = [ { :text => 'first', :dependencies => [ { :text => 'second', :dependencies => [ { :text => 'third' } ] }, { :text => 'fourth' } ] }, { :text => 'fifth' } ] subject.format_tree(list).should == <<-TREE ├─┬ first │ ├─┬ second │ │ └── third │ └── fourth └── fifth TREE end end describe '.set_option_defaults' do let(:options) { {} } let(:modulepath) { ['/env/module/path', '/global/module/path'] } let(:environment_name) { :current_environment } let(:environment) { Puppet::Node::Environment.create(environment_name, modulepath) } subject do described_class.set_option_defaults(options) options end around do |example| envs = Puppet::Environments::Combined.new( Puppet::Environments::Static.new(environment), Puppet::Environments::Legacy.new ) Puppet.override(:environments => envs) do example.run end end describe ':environment' do context 'as String' do let(:options) { { :environment => "#{environment_name}" } } it 'assigns the environment with the given name to :environment_instance' do expect(subject).to include :environment_instance => environment end end context 'as Symbol' do let(:options) { { :environment => :"#{environment_name}" } } it 'assigns the environment with the given name to :environment_instance' do expect(subject).to include :environment_instance => environment end end context 'as Puppet::Node::Environment' do let(:env) { Puppet::Node::Environment.create('anonymous', []) } let(:options) { { :environment => env } } it 'assigns the given environment to :environment_instance' do expect(subject).to include :environment_instance => env end end end describe ':modulepath' do let(:options) do { :modulepath => %w[bar foo baz].join(File::PATH_SEPARATOR) } end let(:paths) { options[:modulepath].split(File::PATH_SEPARATOR).map { |dir| File.expand_path(dir) } } it 'is expanded to an absolute path' do expect(subject[:environment_instance].full_modulepath).to eql paths end it 'is used to compute :target_dir' do expect(subject).to include :target_dir => paths.first end context 'conflicts with :environment' do let(:options) do { :modulepath => %w[bar foo baz].join(File::PATH_SEPARATOR), :environment => environment_name } end it 'replaces the modulepath of the :environment_instance' do expect(subject[:environment_instance].full_modulepath).to eql paths end it 'is used to compute :target_dir' do expect(subject).to include :target_dir => paths.first end end end describe ':target_dir' do let(:options) do { :target_dir => 'foo' } end let(:target) { File.expand_path(options[:target_dir]) } it 'is expanded to an absolute path' do expect(subject).to include :target_dir => target end it 'is prepended to the modulepath of the :environment_instance' do expect(subject[:environment_instance].full_modulepath.first).to eql target end context 'conflicts with :modulepath' do let(:options) do { :target_dir => 'foo', :modulepath => %w[bar foo baz].join(File::PATH_SEPARATOR) } end it 'is prepended to the modulepath of the :environment_instance' do expect(subject[:environment_instance].full_modulepath.first).to eql target end it 'shares the provided :modulepath via the :environment_instance' do paths = %w[foo] + options[:modulepath].split(File::PATH_SEPARATOR) paths.map! { |dir| File.expand_path(dir) } expect(subject[:environment_instance].full_modulepath).to eql paths end end context 'conflicts with :environment' do let(:options) do { :target_dir => 'foo', :environment => environment_name } end it 'is prepended to the modulepath of the :environment_instance' do expect(subject[:environment_instance].full_modulepath.first).to eql target end it 'shares the provided :modulepath via the :environment_instance' do paths = %w[foo] + environment.full_modulepath paths.map! { |dir| File.expand_path(dir) } expect(subject[:environment_instance].full_modulepath).to eql paths end end context 'when not passed' do it 'is populated with the first component of the modulepath' do expect(subject).to include :target_dir => subject[:environment_instance].full_modulepath.first end end end end + + describe '.parse_module_dependency' do + it 'parses a dependency without a version range expression' do + name, range, expr = subject.parse_module_dependency('source', 'name' => 'foo-bar') + expect(name).to eql('foo-bar') + expect(range).to eql(Semantic::VersionRange.parse('>= 0.0.0')) + expect(expr).to eql('>= 0.0.0') + end + + it 'parses a dependency with a version range expression' do + name, range, expr = subject.parse_module_dependency('source', 'name' => 'foo-bar', 'version_requirement' => '1.2.x') + expect(name).to eql('foo-bar') + expect(range).to eql(Semantic::VersionRange.parse('1.2.x')) + expect(expr).to eql('1.2.x') + end + + it 'parses a dependency with a version range expression in the (deprecated) versionRange key' do + name, range, expr = subject.parse_module_dependency('source', 'name' => 'foo-bar', 'versionRequirement' => '1.2.x') + expect(name).to eql('foo-bar') + expect(range).to eql(Semantic::VersionRange.parse('1.2.x')) + expect(expr).to eql('1.2.x') + end + + it 'does not raise an error on invalid version range expressions' do + name, range, expr = subject.parse_module_dependency('source', 'name' => 'foo-bar', 'version_requirement' => 'nope') + expect(name).to eql('foo-bar') + expect(range).to eql(Semantic::VersionRange::EMPTY_RANGE) + expect(expr).to eql('nope') + end + end end diff --git a/spec/unit/util/execution_spec.rb b/spec/unit/util/execution_spec.rb index 5587b4401..7c6238f9f 100755 --- a/spec/unit/util/execution_spec.rb +++ b/spec/unit/util/execution_spec.rb @@ -1,637 +1,637 @@ #! /usr/bin/env ruby require 'spec_helper' describe Puppet::Util::Execution do include Puppet::Util::Execution # utility method to help deal with some windows vs. unix differences def process_status(exitstatus) return exitstatus if Puppet.features.microsoft_windows? stub('child_status', :exitstatus => exitstatus) end # utility methods to help us test some private methods without being quite so verbose def call_exec_posix(command, arguments, stdin, stdout, stderr) Puppet::Util::Execution.send(:execute_posix, command, arguments, stdin, stdout, stderr) end def call_exec_windows(command, arguments, stdin, stdout, stderr) Puppet::Util::Execution.send(:execute_windows, command, arguments, stdin, stdout, stderr) end describe "execution methods" do let(:pid) { 5501 } let(:process_handle) { 0xDEADBEEF } let(:thread_handle) { 0xCAFEBEEF } let(:proc_info_stub) { stub 'processinfo', :process_handle => process_handle, :thread_handle => thread_handle, :process_id => pid} let(:null_file) { Puppet.features.microsoft_windows? ? 'NUL' : '/dev/null' } def stub_process_wait(exitstatus) if Puppet.features.microsoft_windows? Puppet::Util::Windows::Process.stubs(:wait_process).with(process_handle).returns(exitstatus) Process.stubs(:CloseHandle).with(process_handle) Process.stubs(:CloseHandle).with(thread_handle) else Process.stubs(:waitpid2).with(pid).returns([pid, stub('child_status', :exitstatus => exitstatus)]) end end describe "#execute_posix (stubs)", :unless => Puppet.features.microsoft_windows? do before :each do # Most of the things this method does are bad to do during specs. :/ Kernel.stubs(:fork).returns(pid).yields Process.stubs(:setsid) Kernel.stubs(:exec) Puppet::Util::SUIDManager.stubs(:change_user) Puppet::Util::SUIDManager.stubs(:change_group) # ensure that we don't really close anything! (0..256).each {|n| IO.stubs(:new) } $stdin.stubs(:reopen) $stdout.stubs(:reopen) $stderr.stubs(:reopen) @stdin = File.open(null_file, 'r') @stdout = Tempfile.new('stdout') @stderr = File.open(null_file, 'w') # there is a danger here that ENV will be modified by exec_posix. Normally it would only affect the ENV # of a forked process, but here, we're stubbing Kernel.fork, so the method has the ability to override the # "real" ENV. To guard against this, we'll capture a snapshot of ENV before each test. @saved_env = ENV.to_hash # Now, we're going to effectively "mock" the magic ruby 'ENV' variable by creating a local definition of it # inside of the module we're testing. Puppet::Util::Execution::ENV = {} end after :each do # And here we remove our "mock" version of 'ENV', which will allow us to validate that the real ENV has been # left unharmed. Puppet::Util::Execution.send(:remove_const, :ENV) # capture the current environment and make sure it's the same as it was before the test cur_env = ENV.to_hash # we will get some fairly useless output if we just use the raw == operator on the hashes here, so we'll # be a bit more explicit and laborious in the name of making the error more useful... @saved_env.each_pair { |key,val| cur_env[key].should == val } (cur_env.keys - @saved_env.keys).should == [] end it "should fork a child process to execute the command" do Kernel.expects(:fork).returns(pid).yields Kernel.expects(:exec).with('test command') call_exec_posix('test command', {}, @stdin, @stdout, @stderr) end it "should start a new session group" do Process.expects(:setsid) call_exec_posix('test command', {}, @stdin, @stdout, @stderr) end it "should permanently change to the correct user and group if specified" do Puppet::Util::SUIDManager.expects(:change_group).with(55, true) Puppet::Util::SUIDManager.expects(:change_user).with(50, true) call_exec_posix('test command', {:uid => 50, :gid => 55}, @stdin, @stdout, @stderr) end it "should exit failure if there is a problem execing the command" do Kernel.expects(:exec).with('test command').raises("failed to execute!") Puppet::Util::Execution.stubs(:puts) Puppet::Util::Execution.expects(:exit!).with(1) call_exec_posix('test command', {}, @stdin, @stdout, @stderr) end it "should properly execute commands specified as arrays" do Kernel.expects(:exec).with('test command', 'with', 'arguments') call_exec_posix(['test command', 'with', 'arguments'], {:uid => 50, :gid => 55}, @stdin, @stdout, @stderr) end it "should properly execute string commands with embedded newlines" do Kernel.expects(:exec).with("/bin/echo 'foo' ; \n /bin/echo 'bar' ;") call_exec_posix("/bin/echo 'foo' ; \n /bin/echo 'bar' ;", {:uid => 50, :gid => 55}, @stdin, @stdout, @stderr) end it "should return the pid of the child process" do call_exec_posix('test command', {}, @stdin, @stdout, @stderr).should == pid end end describe "#execute_windows (stubs)", :if => Puppet.features.microsoft_windows? do before :each do Process.stubs(:create).returns(proc_info_stub) stub_process_wait(0) @stdin = File.open(null_file, 'r') @stdout = Tempfile.new('stdout') @stderr = File.open(null_file, 'w') end it "should create a new process for the command" do Process.expects(:create).with( :command_line => "test command", :startup_info => {:stdin => @stdin, :stdout => @stdout, :stderr => @stderr}, :close_handles => false ).returns(proc_info_stub) call_exec_windows('test command', {}, @stdin, @stdout, @stderr) end it "should return the process info of the child process" do call_exec_windows('test command', {}, @stdin, @stdout, @stderr).should == proc_info_stub end it "should quote arguments containing spaces if command is specified as an array" do Process.expects(:create).with do |args| args[:command_line] == '"test command" with some "arguments \"with spaces"' end.returns(proc_info_stub) call_exec_windows(['test command', 'with', 'some', 'arguments "with spaces'], {}, @stdin, @stdout, @stderr) end end describe "#execute (stubs)" do before :each do stub_process_wait(0) end describe "when an execution stub is specified" do before :each do Puppet::Util::ExecutionStub.set do |command,args,stdin,stdout,stderr| "execution stub output" end end it "should call the block on the stub" do Puppet::Util::Execution.execute("/usr/bin/run_my_execute_stub").should == "execution stub output" end it "should not actually execute anything" do Puppet::Util::Execution.expects(:execute_posix).never Puppet::Util::Execution.expects(:execute_windows).never Puppet::Util::Execution.execute("/usr/bin/run_my_execute_stub") end end describe "when setting up input and output files" do include PuppetSpec::Files let(:executor) { Puppet.features.microsoft_windows? ? 'execute_windows' : 'execute_posix' } let(:rval) { Puppet.features.microsoft_windows? ? proc_info_stub : pid } before :each do Puppet::Util::Execution.stubs(:wait_for_output) end it "should set stdin to the stdinfile if specified" do input = tmpfile('stdin') FileUtils.touch(input) Puppet::Util::Execution.expects(executor).with do |_,_,stdin,_,_| stdin.path == input end.returns(rval) Puppet::Util::Execution.execute('test command', :stdinfile => input) end it "should set stdin to the null file if not specified" do Puppet::Util::Execution.expects(executor).with do |_,_,stdin,_,_| stdin.path == null_file end.returns(rval) Puppet::Util::Execution.execute('test command') end describe "when squelch is set" do it "should set stdout and stderr to the null file" do Puppet::Util::Execution.expects(executor).with do |_,_,_,stdout,stderr| stdout.path == null_file and stderr.path == null_file end.returns(rval) Puppet::Util::Execution.execute('test command', :squelch => true) end end describe "when squelch is not set" do it "should set stdout to a temporary output file" do outfile = Tempfile.new('stdout') Tempfile.stubs(:new).returns(outfile) Puppet::Util::Execution.expects(executor).with do |_,_,_,stdout,_| stdout.path == outfile.path end.returns(rval) Puppet::Util::Execution.execute('test command', :squelch => false) end it "should set stderr to the same file as stdout if combine is true" do outfile = Tempfile.new('stdout') Tempfile.stubs(:new).returns(outfile) Puppet::Util::Execution.expects(executor).with do |_,_,_,stdout,stderr| stdout.path == outfile.path and stderr.path == outfile.path end.returns(rval) Puppet::Util::Execution.execute('test command', :squelch => false, :combine => true) end it "should set stderr to the null device if combine is false" do outfile = Tempfile.new('stdout') Tempfile.stubs(:new).returns(outfile) Puppet::Util::Execution.expects(executor).with do |_,_,_,stdout,stderr| stdout.path == outfile.path and stderr.path == null_file end.returns(rval) Puppet::Util::Execution.execute('test command', :squelch => false, :combine => false) end it "should combine stdout and stderr if combine is true" do outfile = Tempfile.new('stdout') Tempfile.stubs(:new).returns(outfile) Puppet::Util::Execution.expects(executor).with do |_,_,_,stdout,stderr| stdout.path == outfile.path and stderr.path == outfile.path end.returns(rval) Puppet::Util::Execution.execute('test command', :combine => true) end it "should default combine to true when no options are specified" do outfile = Tempfile.new('stdout') Tempfile.stubs(:new).returns(outfile) Puppet::Util::Execution.expects(executor).with do |_,_,_,stdout,stderr| stdout.path == outfile.path and stderr.path == outfile.path end.returns(rval) Puppet::Util::Execution.execute('test command') end it "should default combine to false when options are specified, but combine is not" do outfile = Tempfile.new('stdout') Tempfile.stubs(:new).returns(outfile) Puppet::Util::Execution.expects(executor).with do |_,_,_,stdout,stderr| stdout.path == outfile.path and stderr.path == null_file end.returns(rval) Puppet::Util::Execution.execute('test command', :failonfail => false) end it "should default combine to false when an empty hash of options is specified" do outfile = Tempfile.new('stdout') Tempfile.stubs(:new).returns(outfile) Puppet::Util::Execution.expects(executor).with do |_,_,_,stdout,stderr| stdout.path == outfile.path and stderr.path == null_file end.returns(rval) Puppet::Util::Execution.execute('test command', {}) end end end describe "on Windows", :if => Puppet.features.microsoft_windows? do it "should always close the process and thread handles" do Puppet::Util::Execution.stubs(:execute_windows).returns(proc_info_stub) Puppet::Util::Windows::Process.expects(:wait_process).with(process_handle).raises('whatever') Puppet::Util::Windows::Process.expects(:CloseHandle).with(thread_handle) Puppet::Util::Windows::Process.expects(:CloseHandle).with(process_handle) expect { Puppet::Util::Execution.execute('test command') }.to raise_error(RuntimeError) end it "should return the correct exit status even when exit status is greater than 256" do real_exit_status = 3010 Puppet::Util::Execution.stubs(:execute_windows).returns(proc_info_stub) stub_process_wait(real_exit_status) $CHILD_STATUS.stubs(:exitstatus).returns(real_exit_status % 256) # The exitstatus is changed to be mod 256 so that ruby can fit it into 8 bits. Puppet::Util::Execution.execute('test command', :failonfail => false).exitstatus.should == real_exit_status end end end describe "#execute (posix locale)", :unless => Puppet.features.microsoft_windows? do before :each do # there is a danger here that ENV will be modified by exec_posix. Normally it would only affect the ENV # of a forked process, but, in some of the previous tests in this file we're stubbing Kernel.fork., which could # allow the method to override the "real" ENV. This shouldn't be a problem for these tests because they are # not stubbing Kernel.fork, but, better safe than sorry... so, to guard against this, we'll capture a snapshot # of ENV before each test. @saved_env = ENV.to_hash end after :each do # capture the current environment and make sure it's the same as it was before the test cur_env = ENV.to_hash # we will get some fairly useless output if we just use the raw == operator on the hashes here, so we'll # be a bit more explicit and laborious in the name of making the error more useful... @saved_env.each_pair { |key,val| cur_env[key].should == val } (cur_env.keys - @saved_env.keys).should == [] end # build up a printf-style string that contains a command to get the value of an environment variable # from the operating system. We can substitute into this with the names of the desired environment variables later. get_env_var_cmd = 'echo $%s' # a sentinel value that we can use to emulate what locale environment variables might be set to on an international # system. lang_sentinel_value = "en_US.UTF-8" # a temporary hash that contains sentinel values for each of the locale environment variables that we override in # "execute" locale_sentinel_env = {} Puppet::Util::POSIX::LOCALE_ENV_VARS.each { |var| locale_sentinel_env[var] = lang_sentinel_value } it "should override the locale environment variables when :override_locale is not set (defaults to true)" do # temporarily override the locale environment vars with a sentinel value, so that we can confirm that # execute is actually setting them. Puppet::Util.withenv(locale_sentinel_env) do Puppet::Util::POSIX::LOCALE_ENV_VARS.each do |var| # we expect that all of the POSIX vars will have been cleared except for LANG and LC_ALL expected_value = (['LANG', 'LC_ALL'].include?(var)) ? "C" : "" Puppet::Util::execute(get_env_var_cmd % var).strip.should == expected_value end end end it "should override the LANG environment variable when :override_locale is set to true" do # temporarily override the locale environment vars with a sentinel value, so that we can confirm that # execute is actually setting them. Puppet::Util.withenv(locale_sentinel_env) do Puppet::Util::POSIX::LOCALE_ENV_VARS.each do |var| # we expect that all of the POSIX vars will have been cleared except for LANG and LC_ALL expected_value = (['LANG', 'LC_ALL'].include?(var)) ? "C" : "" Puppet::Util::execute(get_env_var_cmd % var, {:override_locale => true}).strip.should == expected_value end end end it "should *not* override the LANG environment variable when :override_locale is set to false" do # temporarily override the locale environment vars with a sentinel value, so that we can confirm that # execute is not setting them. Puppet::Util.withenv(locale_sentinel_env) do Puppet::Util::POSIX::LOCALE_ENV_VARS.each do |var| Puppet::Util::execute(get_env_var_cmd % var, {:override_locale => false}).strip.should == lang_sentinel_value end end end it "should have restored the LANG and locale environment variables after execution" do # we'll do this once without any sentinel values, to give us a little more test coverage orig_env_vals = {} Puppet::Util::POSIX::LOCALE_ENV_VARS.each do |var| orig_env_vals[var] = ENV[var] end # now we can really execute any command--doesn't matter what it is... Puppet::Util::execute(get_env_var_cmd % 'anything', {:override_locale => true}) # now we check and make sure the original environment was restored Puppet::Util::POSIX::LOCALE_ENV_VARS.each do |var| ENV[var].should == orig_env_vals[var] end # now, once more... but with our sentinel values Puppet::Util.withenv(locale_sentinel_env) do # now we can really execute any command--doesn't matter what it is... Puppet::Util::execute(get_env_var_cmd % 'anything', {:override_locale => true}) # now we check and make sure the original environment was restored Puppet::Util::POSIX::LOCALE_ENV_VARS.each do |var| ENV[var].should == locale_sentinel_env[var] end end end end describe "#execute (posix user env vars)", :unless => Puppet.features.microsoft_windows? do # build up a printf-style string that contains a command to get the value of an environment variable # from the operating system. We can substitute into this with the names of the desired environment variables later. get_env_var_cmd = 'echo $%s' # a sentinel value that we can use to emulate what locale environment variables might be set to on an international # system. user_sentinel_value = "Abracadabra" # a temporary hash that contains sentinel values for each of the locale environment variables that we override in # "execute" user_sentinel_env = {} Puppet::Util::POSIX::USER_ENV_VARS.each { |var| user_sentinel_env[var] = user_sentinel_value } it "should unset user-related environment vars during execution" do # first we set up a temporary execution environment with sentinel values for the user-related environment vars # that we care about. Puppet::Util.withenv(user_sentinel_env) do # with this environment, we loop over the vars in question Puppet::Util::POSIX::USER_ENV_VARS.each do |var| # ensure that our temporary environment is set up as we expect ENV[var].should == user_sentinel_env[var] # run an "exec" via the provider and ensure that it unsets the vars Puppet::Util::execute(get_env_var_cmd % var).strip.should == "" # ensure that after the exec, our temporary env is still intact ENV[var].should == user_sentinel_env[var] end end end it "should have restored the user-related environment variables after execution" do # we'll do this once without any sentinel values, to give us a little more test coverage orig_env_vals = {} Puppet::Util::POSIX::USER_ENV_VARS.each do |var| orig_env_vals[var] = ENV[var] end # now we can really execute any command--doesn't matter what it is... Puppet::Util::execute(get_env_var_cmd % 'anything') # now we check and make sure the original environment was restored Puppet::Util::POSIX::USER_ENV_VARS.each do |var| ENV[var].should == orig_env_vals[var] end # now, once more... but with our sentinel values Puppet::Util.withenv(user_sentinel_env) do # now we can really execute any command--doesn't matter what it is... Puppet::Util::execute(get_env_var_cmd % 'anything') # now we check and make sure the original environment was restored Puppet::Util::POSIX::USER_ENV_VARS.each do |var| ENV[var].should == user_sentinel_env[var] end end end end describe "after execution" do before :each do stub_process_wait(0) if Puppet.features.microsoft_windows? Puppet::Util::Execution.stubs(:execute_windows).returns(proc_info_stub) else Puppet::Util::Execution.stubs(:execute_posix).returns(pid) end end it "should wait for the child process to exit" do Puppet::Util::Execution.stubs(:wait_for_output) Puppet::Util::Execution.execute('test command') end it "should close the stdin/stdout/stderr files used by the child" do stdin = mock 'file', :close stdout = mock 'file', :close stderr = mock 'file', :close File.expects(:open). times(3). returns(stdin). then.returns(stdout). then.returns(stderr) Puppet::Util::Execution.execute('test command', {:squelch => true, :combine => false}) end it "should read and return the output if squelch is false" do stdout = Tempfile.new('test') Tempfile.stubs(:new).returns(stdout) stdout.write("My expected command output") Puppet::Util::Execution.execute('test command').should == "My expected command output" end it "should not read the output if squelch is true" do stdout = Tempfile.new('test') Tempfile.stubs(:new).returns(stdout) stdout.write("My expected command output") Puppet::Util::Execution.execute('test command', :squelch => true).should == '' end it "should delete the file used for output if squelch is false" do stdout = Tempfile.new('test') path = stdout.path Tempfile.stubs(:new).returns(stdout) Puppet::Util::Execution.execute('test command') Puppet::FileSystem.exist?(path).should be_false end it "should not raise an error if the file is open" do stdout = Tempfile.new('test') Tempfile.stubs(:new).returns(stdout) file = File.new(stdout.path, 'r') Puppet::Util.execute('test command') end it "should raise an error if failonfail is true and the child failed" do stub_process_wait(1) expect { subject.execute('fail command', :failonfail => true) }.to raise_error(Puppet::ExecutionFailure, /Execution of 'fail command' returned 1/) end it "should not raise an error if failonfail is false and the child failed" do stub_process_wait(1) subject.execute('fail command', :failonfail => false) end it "should not raise an error if failonfail is true and the child succeeded" do stub_process_wait(0) subject.execute('fail command', :failonfail => true) end it "should not raise an error if failonfail is false and the child succeeded" do stub_process_wait(0) subject.execute('fail command', :failonfail => false) end it "should default failonfail to true when no options are specified" do stub_process_wait(1) expect { subject.execute('fail command') }.to raise_error(Puppet::ExecutionFailure, /Execution of 'fail command' returned 1/) end it "should default failonfail to false when options are specified, but failonfail is not" do stub_process_wait(1) subject.execute('fail command', { :combine => true }) end it "should default failonfail to false when an empty hash of options is specified" do stub_process_wait(1) subject.execute('fail command', {}) end it "should raise an error if a nil option is specified" do expect { Puppet::Util::Execution.execute('fail command', nil) }.to raise_error(TypeError, /(can\'t convert|no implicit conversion of) nil into Hash/) end end end describe "#execpipe" do it "should execute a string as a string" do - Puppet::Util::Execution.expects(:open).with('| echo hello 2>&1', 'r').returns('hello') + Puppet::Util::Execution.expects(:open).with('| echo hello 2>&1').returns('hello') $CHILD_STATUS.expects(:==).with(0).returns(true) Puppet::Util::Execution.execpipe('echo hello').should == 'hello' end it "should print meaningful debug message for string argument" do Puppet::Util::Execution.expects(:debug).with("Executing 'echo hello'") - Puppet::Util::Execution.expects(:open).with('| echo hello 2>&1', 'r').returns('hello') + Puppet::Util::Execution.expects(:open).with('| echo hello 2>&1').returns('hello') $CHILD_STATUS.expects(:==).with(0).returns(true) Puppet::Util::Execution.execpipe('echo hello') end it "should print meaningful debug message for array argument" do Puppet::Util::Execution.expects(:debug).with("Executing 'echo hello'") - Puppet::Util::Execution.expects(:open).with('| echo hello 2>&1', 'r').returns('hello') + Puppet::Util::Execution.expects(:open).with('| echo hello 2>&1').returns('hello') $CHILD_STATUS.expects(:==).with(0).returns(true) Puppet::Util::Execution.execpipe(['echo','hello']) end it "should execute an array by pasting together with spaces" do - Puppet::Util::Execution.expects(:open).with('| echo hello 2>&1', 'r').returns('hello') + Puppet::Util::Execution.expects(:open).with('| echo hello 2>&1').returns('hello') $CHILD_STATUS.expects(:==).with(0).returns(true) Puppet::Util::Execution.execpipe(['echo', 'hello']).should == 'hello' end it "should fail if asked to fail, and the child does" do Puppet::Util::Execution.stubs(:open).returns('error message') $CHILD_STATUS.expects(:==).with(0).returns(false) expect { Puppet::Util::Execution.execpipe('echo hello') }. to raise_error Puppet::ExecutionFailure, /error message/ end it "should not fail if asked not to fail, and the child does" do Puppet::Util::Execution.stubs(:open).returns('error message') $CHILD_STATUS.stubs(:==).with(0).returns(false) Puppet::Util::Execution.execpipe('echo hello', false).should == 'error message' end end end