diff --git a/lib/puppet/provider.rb b/lib/puppet/provider.rb index d902206f7..61b8f38ed 100644 --- a/lib/puppet/provider.rb +++ b/lib/puppet/provider.rb @@ -1,291 +1,375 @@ # The container class for implementations. class Puppet::Provider include Puppet::Util::Execution include Puppet::Util include Puppet::Util::Errors include Puppet::Util::Warnings extend Puppet::Util::Warnings require 'puppet/provider/confiner' + require 'puppet/provider/command' extend Puppet::Provider::Confiner Puppet::Util.logmethods(self, true) class << self # Include the util module so we have access to things like 'which' include Puppet::Util, Puppet::Util::Docs include Puppet::Util::Logging attr_accessor :name # The source parameter exists so that providers using the same # source can specify this, so reading doesn't attempt to read the # same package multiple times. attr_writer :source # LAK 2007-05-09: Keep the model stuff around for backward compatibility attr_reader :model attr_accessor :resource_type attr_writer :doc end # LAK 2007-05-09: Keep the model stuff around for backward compatibility attr_reader :model attr_accessor :resource def self.command(name) name = symbolize(name) if defined?(@commands) and command = @commands[name] # nothing elsif superclass.respond_to? :command and command = superclass.command(name) # nothing else raise Puppet::DevError, "No command #{name} defined for provider #{self.name}" end which(command) end # Define commands that are not optional. - def self.commands(hash) - optional_commands(hash) do |name, path| - confine :exists => path, :for_binary => true + # + # @param [Hash{String => String}] command_specs Named commands that the provider will + # be executing on the system. Each command is specified with a name and the path of the executable. + # (@see #has_command) + def self.commands(command_specs) + command_specs.each do |name, path| + has_command(name, path) + end + end + + # Define commands that are optional. + # + # @param [Hash{String => String}] command_specs Named commands that the provider will + # be executing on the system. Each command is specified with a name and the path of the executable. + # (@see #has_command) + def self.optional_commands(hash) + hash.each do |name, target| + has_command(name, target) do + is_optional + end + end + end + + # Define a single command + # + # A method will be generated on the provider that allows easy execution of the command. The generated + # method can take arguments that will be passed through to the executable as the command line arguments + # when it is run. + # + # has_command(:echo, "/bin/echo") + # def some_method + # echo("arg 1", "arg 2") + # end + # + # # or + # + # has_command(:echo, "/bin/echo") do + # is_optional + # environment :HOME => "/var/tmp", :PWD => "/tmp" + # end + # + # @param [Symbol] name Name of the command (will be the name of the generated method to call the command) + # @param [String] path The path to the executable for the command + # @yield A block that configures the command (@see Puppet::Provider::Command) + def self.has_command(name, path, &block) + name = symbolize(name) + configuration = block_given? ? block : Proc.new {} + command = CommandDefiner.define(name, path, self, &configuration) + + @commands[name] = command.executable + + # Now define the class and instance methods. + create_class_and_instance_method(name) do |*args| + return command.execute(*args) + end + end + + class CommandDefiner + private_class_method :new + + def self.define(name, path, confiner, &block) + definer = new(name, path, confiner) + definer.instance_eval &block + definer.command + end + + def initialize(name, path, confiner) + @name = name + @path = path + @optional = false + @confiner = confiner + @custom_environment = {} + end + + def is_optional + @optional = true + end + + def environment(env) + @custom_environment = @custom_environment.merge(env) + end + + def command + if not @optional + @confiner.confine :exists => @path, :for_binary => true + end + + Puppet::Provider::Command.new(@name, @path, Puppet::Util, Puppet::Util::Execution, { :custom_environment => @custom_environment }) end end # Is the provided feature a declared feature? def self.declared_feature?(name) defined?(@declared_features) and @declared_features.include?(name) end # Does this implementation match all of the default requirements? If # defaults are empty, we return false. def self.default? return false if @defaults.empty? if @defaults.find do |fact, values| values = [values] unless values.is_a? Array if fval = Facter.value(fact).to_s and fval != "" fval = fval.to_s.downcase.intern else return false end # If any of the values match, we're a default. if values.find do |value| fval == value.to_s.downcase.intern end false else true end end return false else return true end end # Store how to determine defaults. def self.defaultfor(hash) hash.each do |d,v| @defaults[d] = v end end def self.specificity (@defaults.length * 100) + ancestors.select { |a| a.is_a? Class }.length end def self.initvars @defaults = {} @commands = {} end # The method for returning a list of provider instances. Note that it returns providers, preferably with values already # filled in, not resources. def self.instances raise Puppet::DevError, "Provider #{self.name} has not defined the 'instances' class method" end # Create the methods for a given command. + # + # @deprecated Use {#commands}, {#optional_commands}, or {#has_command} instead. This was not meant to be part of a public API def self.make_command_methods(name) + Puppet.deprecation_warning "Provider.make_command_methods is deprecated; use Provider.commands or Provider.optional_commands instead for creating command methods" + # Now define a method for that command unless singleton_class.method_defined?(name) meta_def(name) do |*args| - raise Puppet::Error, "Command #{name} is missing" unless command(name) - if args.empty? - cmd = [command(name)] - else - cmd = [command(name)] + args - end # This might throw an ExecutionFailure, but the system above # will catch it, if so. - return execute(cmd) + command = Puppet::Provider::Command.new(name, command(name), Puppet::Util, Puppet::Util::Execution) + return command.execute(*args) end # And then define an instance method that just calls the class method. # We need both, so both instances and classes can easily run the commands. unless method_defined?(name) define_method(name) do |*args| self.class.send(name, *args) end end end end # Create getter/setter methods for each property our resource type supports. # They all get stored in @property_hash. This method is useful # for those providers that use prefetch and flush. def self.mkmodelmethods Puppet.deprecation_warning "Provider.mkmodelmethods is deprecated; use Provider.mk_resource_methods" mk_resource_methods end # Create getter/setter methods for each property our resource type supports. # They all get stored in @property_hash. This method is useful # for those providers that use prefetch and flush. def self.mk_resource_methods [resource_type.validproperties, resource_type.parameters].flatten.each do |attr| attr = symbolize(attr) next if attr == :name define_method(attr) do @property_hash[attr] || :absent end define_method(attr.to_s + "=") do |val| @property_hash[attr] = val end end end self.initvars - # Define one or more binaries we'll be using. If a block is passed, yield the name - # and path to the block (really only used by 'commands'). - def self.optional_commands(hash) - hash.each do |name, path| - name = symbolize(name) - @commands[name] = path - - yield(name, path) if block_given? + def self.create_class_and_instance_method(name, &block) + unless singleton_class.method_defined?(name) + meta_def(name, &block) + end - # Now define the class and instance methods. - make_command_methods(name) + unless method_defined?(name) + define_method(name) do |*args| + self.class.send(name, *args) + end end end + private_class_method :create_class_and_instance_method # Retrieve the data source. Defaults to the provider name. def self.source @source ||= self.name end # Does this provider support the specified parameter? def self.supports_parameter?(param) if param.is_a?(Class) klass = param else unless klass = resource_type.attrclass(param) raise Puppet::DevError, "'#{param}' is not a valid parameter for #{resource_type.name}" end end return true unless features = klass.required_features !!satisfies?(*features) end # def self.to_s # unless defined?(@str) # if self.resource_type # @str = "#{resource_type.name} provider #{self.name}" # else # @str = "unattached provider #{self.name}" # end # end # @str # end dochook(:defaults) do if @defaults.length > 0 return "Default for " + @defaults.collect do |f, v| "`#{f}` == `#{[v].flatten.join(', ')}`" end.join(" and ") + "." end end dochook(:commands) do if @commands.length > 0 return "Required binaries: " + @commands.collect do |n, c| "`#{c}`" end.join(", ") + "." end end dochook(:features) do if features.length > 0 return "Supported features: " + features.collect do |f| "`#{f}`" end.join(", ") + "." end end # Remove the reference to the resource, so GC can clean up. def clear @resource = nil @model = nil end # Retrieve a named command. def command(name) self.class.command(name) end # Get a parameter value. def get(param) @property_hash[symbolize(param)] || :absent end def initialize(resource = nil) if resource.is_a?(Hash) # We don't use a duplicate here, because some providers (ParsedFile, at least) # use the hash here for later events. @property_hash = resource elsif resource @resource = resource # LAK 2007-05-09: Keep the model stuff around for backward compatibility @model = resource @property_hash = {} else @property_hash = {} end end def name if n = @property_hash[:name] return n elsif self.resource resource.name else raise Puppet::DevError, "No resource and no name in property hash in #{self.class.name} instance" end end # Set passed params as the current values. def set(params) params.each do |param, value| @property_hash[symbolize(param)] = value end end def to_s "#{@resource}(provider=#{self.class.name})" end # Make providers comparable. include Comparable def <=>(other) # We can only have ordering against other providers. return nil unless other.is_a? Puppet::Provider # Otherwise, order by the providers class name. return self.class.name <=> other.class.name end end diff --git a/lib/puppet/provider/command.rb b/lib/puppet/provider/command.rb new file mode 100644 index 000000000..b3b377674 --- /dev/null +++ b/lib/puppet/provider/command.rb @@ -0,0 +1,25 @@ +# A command that can be executed on the system +class Puppet::Provider::Command + attr_reader :executable + attr_reader :name + + # @param [String] name A way of referencing the name + # @param [String] executable The path to the executable file + # @param resolver An object for resolving the executable to an absolute path (usually Puppet::Util) + # @param executor An object for performing the actual execution of the command (usually Puppet::Util::Execution) + # @param [Hash] options Extra options to be used when executing (see Puppet::Util::Execution#execute) + def initialize(name, executable, resolver, executor, options = {}) + @name = name + @executable = executable + @resolver = resolver + @executor = executor + @options = options + end + + # @param [Array] Any command line arguments to pass to the executable + # @returns The output from the command + def execute(*args) + resolved_executable = @resolver.which(@executable) or raise Puppet::Error, "Command #{@name} is missing" + @executor.execute([resolved_executable] + args, @options) + end +end diff --git a/lib/puppet/provider/package/macports.rb b/lib/puppet/provider/package/macports.rb index 3df29af8b..339a5775a 100755 --- a/lib/puppet/provider/package/macports.rb +++ b/lib/puppet/provider/package/macports.rb @@ -1,103 +1,107 @@ require 'puppet/provider/package' +require 'puppet/provider/command' Puppet::Type.type(:package).provide :macports, :parent => Puppet::Provider::Package do desc "Package management using MacPorts on OS X. Supports MacPorts versions and revisions, but not variants. Variant preferences may be specified using [the MacPorts variants.conf file](http://guide.macports.org/chunked/internals.configuration-files.html#internals.configuration-files.variants-conf). When specifying a version in the Puppet DSL, only specify the version, not the revision. Revisions are only used internally for ensuring the latest version/revision of a port. " confine :operatingsystem => :darwin - commands :port => "/opt/local/bin/port" + + has_command(:port, "/opt/local/bin/port") do + environment :HOME => "/opt/local" + end has_feature :installable has_feature :uninstallable has_feature :upgradeable has_feature :versionable def self.parse_installed_query_line(line) regex = /(\S+)\s+@(\S+)_(\d+).*\(active\)/ fields = [:name, :ensure, :revision] hash_from_line(line, regex, fields) end def self.parse_info_query_line(line) regex = /(\S+)\s+(\S+)/ fields = [:version, :revision] hash_from_line(line, regex, fields) end def self.hash_from_line(line, regex, fields) hash = {} if match = regex.match(line) fields.zip(match.captures) { |field, value| hash[field] = value } hash[:provider] = self.name return hash end nil end def self.instances packages = [] port("-q", :installed).each_line do |line| if hash = parse_installed_query_line(line) packages << new(hash) end end packages end def install should = @resource.should(:ensure) if [:latest, :installed, :present].include?(should) output = port("-q", :install, @resource[:name]) else output = port("-q", :install, @resource[:name], "@#{should}") end # MacPorts now correctly exits non-zero with appropriate errors in # situations where a port cannot be found or installed. end def query result = self.class.parse_installed_query_line(execute([command(:port), "-q", :installed, @resource[:name]], :combine => false)) return {} if result.nil? return result end def latest # We need both the version and the revision to be confident # we've got the latest revision of a specific version # Note we're still not doing anything with variants here. info_line = execute([command(:port), "-q", :info, "--line", "--version", "--revision", @resource[:name]], :combine => false) return nil if info_line == "" if newest = self.class.parse_info_query_line(info_line) current = query # We're doing some fiddling behind the scenes here to cope with updated revisions. # If we're already at the latest version/revision, then just return the version # so the current and desired values match. Otherwise return version and revision # to trigger an upgrade to the latest revision. if newest[:version] == current[:ensure] and newest[:revision] == current[:revision] return current[:ensure] else return "#{newest[:version]}_#{newest[:revision]}" end end nil end def uninstall port("-q", :uninstall, @resource[:name]) end def update install end end diff --git a/lib/puppet/util/execution.rb b/lib/puppet/util/execution.rb index 220cc465e..958d12f0e 100644 --- a/lib/puppet/util/execution.rb +++ b/lib/puppet/util/execution.rb @@ -1,244 +1,248 @@ module Puppet require 'rbconfig' # A command failed to execute. require 'puppet/error' class ExecutionFailure < Puppet::Error end module Util::Execution # Execute the provided command with STDIN connected to a pipe, yielding the # pipe object. That allows data to be fed to that subprocess. # # 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 all cases this is passed directly to the shell, and STDOUT and STDERR # are connected together during execution. def self.execpipe(command, failonfail = true) if respond_to? :debug debug "Executing '#{command}'" else Puppet.debug "Executing '#{command}'" end # 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 output = open("| #{command_str} 2>&1") do |pipe| yield pipe end if failonfail unless $CHILD_STATUS == 0 raise ExecutionFailure, output end end output end def self.execfail(command, exception) output = execute(command) return output rescue ExecutionFailure raise exception, output end # Execute the desired command, and return the status and output. - # def execute(command, arguments) - # [arguments] a Hash optionally containing any of the following keys: + # def execute(command, options) + # [command] an Array or String representing 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. + # [options] a Hash optionally containing any of the following keys: # :failonfail (default true) -- if this value is set to true, then this method will raise an error if the # command is not executed successfully. # :uid (default nil) -- the user id of the user that the process should be run as # :gid (default nil) -- the group id of the group that the process should be run as # :combine (default true) -- sets whether or not to combine stdout/stderr in the output # :stdinfile (default nil) -- sets a file that can be used for stdin. Passing a string for stdin is not currently # supported. # :squelch (default false) -- if true, ignore stdout / stderr completely # :override_locale (default 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. # :custom_environment (default {}) -- a hash of key/value pairs to set as environment variables for the duration # of the command - def self.execute(command, arguments = {}) + def self.execute(command, options = {}) # 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_arguments = { + default_options = { :failonfail => true, :uid => nil, :gid => nil, :combine => true, :stdinfile => nil, :squelch => false, :override_locale => true, :custom_environment => {}, } - arguments = default_arguments.merge(arguments) + 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(arguments[:stdinfile] || null_file, 'r') - stdout = arguments[:squelch] ? File.open(null_file, 'w') : Tempfile.new('puppet') - stderr = arguments[:combine] ? stdout : File.open(null_file, 'w') + 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, arguments, stdin, stdout, stderr] + 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 Process.CloseHandle(process_info.process_handle) Process.CloseHandle(process_info.thread_handle) end end [stdin, stdout, stderr].each {|io| io.close rescue nil} # read output in if required - unless arguments[:squelch] + unless options[:squelch] output = wait_for_output(stdout) Puppet.warning "Could not get output" unless output end - if arguments[:failonfail] and exit_status != 0 + if options[:failonfail] and exit_status != 0 raise ExecutionFailure, "Execution of '#{str}' returned #{exit_status}: #{output}" end output end # get 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) 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, see call to private_class_method after method definition - def self.execute_posix(command, arguments, stdin, stdout, stderr) + 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(arguments[:uid], arguments[:gid], true) + Puppet::Util::SUIDManager.change_privileges(options[:uid], options[:gid], true) # if the caller has requested that we override locale environment variables, - if (arguments[:override_locale]) then + 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) } - arguments[:custom_environment] ||= {} - Puppet::Util.withenv(arguments[:custom_environment]) do + 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, see call to private_class_method after method definition - def self.execute_windows(command, arguments, stdin, stdout, stderr) + 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) - arguments[:custom_environment] ||= {} - Puppet::Util.withenv(arguments[:custom_environment]) do - Puppet::Util::Windows::Process.execute(command, arguments, stdin, stdout, stderr) + 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, see call to private_class_method after method definition 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 File.exists?(stdout.path) output = stdout.open.read stdout.close(true) return output 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 end diff --git a/spec/unit/provider/command_spec.rb b/spec/unit/provider/command_spec.rb new file mode 100755 index 000000000..1afe019ee --- /dev/null +++ b/spec/unit/provider/command_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' + +require 'puppet/provider/command' + +describe Puppet::Provider::Command do + let(:name) { "the name" } + let(:the_options) { { :option => 1 } } + let(:no_options) { {} } + let(:executable) { "foo" } + let(:executable_absolute_path) { "/foo/bar" } + + let(:executor) { mock('executor') } + let(:resolver) { mock('resolver') } + + let(:path_resolves_to_itself) do + resolves = Object.new + class << resolves + def which(path) + path + end + end + resolves + end + + it "executes a simple command" do + executor.expects(:execute).with([executable], no_options) + + command = Puppet::Provider::Command.new(name, executable, path_resolves_to_itself, executor) + command.execute() + end + + it "executes a command with extra options" do + executor.expects(:execute).with([executable], the_options) + + command = Puppet::Provider::Command.new(name, executable, path_resolves_to_itself, executor, the_options) + command.execute() + end + + it "executes a command with arguments" do + executor.expects(:execute).with([executable, "arg1", "arg2"], no_options) + + command = Puppet::Provider::Command.new(name, executable, path_resolves_to_itself, executor) + command.execute("arg1", "arg2") + end + + it "resolves to an absolute path for better execution" do + resolver.expects(:which).with(executable).returns(executable_absolute_path) + executor.expects(:execute).with([executable_absolute_path], no_options) + + command = Puppet::Provider::Command.new(name, executable, resolver, executor) + command.execute() + end + + it "errors when the executable resolves to nothing" do + resolver.expects(:which).with(executable).returns(nil) + executor.expects(:execute).never + + command = Puppet::Provider::Command.new(name, executable, resolver, executor) + + lambda { command.execute() }.should raise_error(Puppet::Error, "Command #{name} is missing") + end +end diff --git a/spec/unit/provider/package/pacman_spec.rb b/spec/unit/provider/package/pacman_spec.rb index 949a2bac5..a37fe5b19 100755 --- a/spec/unit/provider/package/pacman_spec.rb +++ b/spec/unit/provider/package/pacman_spec.rb @@ -1,307 +1,266 @@ #!/usr/bin/env rspec require 'spec_helper' require 'stringio' provider = Puppet::Type.type(:package).provider(:pacman) describe provider do + let(:no_extra_options) { { :custom_environment => {} } } + let(:executor) { Puppet::Util::Execution } + let(:resolver) { Puppet::Util } + before do - provider.stubs(:command).with(:pacman).returns('/usr/bin/pacman') + resolver.stubs(:which).with('/usr/bin/pacman').returns('/usr/bin/pacman') + provider.stubs(:which).with('/usr/bin/pacman').returns('/usr/bin/pacman') @resource = Puppet::Type.type(:package).new(:name => 'package') @provider = provider.new(@resource) end describe "when installing" do before do @provider.stubs(:query).returns({ :ensure => '1.0' }) end - it "should call pacman" do - provider. + it "should call pacman to install the right package quietly" do + executor. expects(:execute). at_least_once. - with { |args| - args[0] == "/usr/bin/pacman" - }. + with(["/usr/bin/pacman", "--noconfirm", "--noprogressbar", "-Sy", @resource[:name]], no_extra_options). returns "" @provider.install end - it "should be quiet" do - provider. - expects(:execute). - with { |args| - args[1,2] == ["--noconfirm", "--noprogressbar"] - }. - returns("") - - @provider.install - end - - it "should install the right package" do - provider. - expects(:execute). - with { |args| - args[3,4] == ["-Sy", @resource[:name]] - }. - returns("") - - @provider.install - end - it "should raise an ExecutionFailure if the installation failed" do - provider.stubs(:execute).returns("") + executor.stubs(:execute).returns("") @provider.expects(:query).returns(nil) lambda { @provider.install }.should raise_exception(Puppet::ExecutionFailure) end context "when :source is specified" do before :each do @install = sequence("install") end context "recognizable by pacman" do %w{ /some/package/file http://some.package.in/the/air ftp://some.package.in/the/air }.each do |source| it "should install #{source} directly" do @resource[:source] = source - provider.expects(:execute). - with(all_of(includes("-Sy"), includes("--noprogressbar"))). + executor.expects(:execute). + with(all_of(includes("-Sy"), includes("--noprogressbar")), no_extra_options). in_sequence(@install). returns("") - provider.expects(:execute). - with(all_of(includes("-U"), includes(source))). + executor.expects(:execute). + with(all_of(includes("-U"), includes(source)), no_extra_options). in_sequence(@install). returns("") @provider.install end end end context "as a file:// URL" do before do @package_file = "file:///some/package/file" @actual_file_path = "/some/package/file" @resource[:source] = @package_file end it "should install from the path segment of the URL" do - provider.expects(:execute). - with(all_of(includes("-Sy"), includes("--noprogressbar"), - includes("--noconfirm"))). + executor.expects(:execute). + with(all_of(includes("-Sy"), + includes("--noprogressbar"), + includes("--noconfirm")), + no_extra_options). in_sequence(@install). returns("") - provider.expects(:execute). - with(all_of(includes("-U"), includes(@actual_file_path))). + executor.expects(:execute). + with(all_of(includes("-U"), includes(@actual_file_path)), no_extra_options). in_sequence(@install). returns("") @provider.install end end context "as a puppet URL" do before do @resource[:source] = "puppet://server/whatever" end it "should fail" do lambda { @provider.install }.should raise_error(Puppet::Error) end end context "as a malformed URL" do before do @resource[:source] = "blah://" end it "should fail" do lambda { @provider.install }.should raise_error(Puppet::Error) end end end end describe "when updating" do it "should call install" do @provider.expects(:install).returns("install return value") @provider.update.should == "install return value" end end describe "when uninstalling" do - it "should call pacman" do - provider. + it "should call pacman to remove the right package quietly" do + executor. expects(:execute). - with { |args| - args[0] == "/usr/bin/pacman" - }. + with(["/usr/bin/pacman", "--noconfirm", "--noprogressbar", "-R", @resource[:name]], no_extra_options). returns "" @provider.uninstall end - - it "should be quiet" do - provider. - expects(:execute). - with { |args| - args[1,2] == ["--noconfirm", "--noprogressbar"] - }. - returns("") - - @provider.uninstall - end - - it "should remove the right package" do - provider. - expects(:execute). - with { |args| - args[3,4] == ["-R", @resource[:name]] - }. - returns("") - - @provider.uninstall - end end describe "when querying" do it "should query pacman" do - provider. + executor. expects(:execute). - with(["/usr/bin/pacman", "-Qi", @resource[:name]]) + with(["/usr/bin/pacman", "-Qi", @resource[:name]], no_extra_options) @provider.query end it "should return the version" do query_output = <=2.7.1 libfetch>=2.25 pacman-mirrorlist Optional Deps : fakeroot: for makepkg usage as normal user curl: for rankmirrors usage Required By : None Conflicts With : None Replaces : None Installed Size : 2352.00 K Packager : Dan McGee Architecture : i686 Build Date : Sat 22 Jan 2011 03:56:41 PM EST Install Date : Thu 27 Jan 2011 06:45:49 AM EST Install Reason : Explicitly installed Install Script : Yes Description : A library-based package manager with dependency support EOF - provider.expects(:execute).returns(query_output) + executor.expects(:execute).returns(query_output) @provider.query.should == {:ensure => "1.01.3-2"} end it "should return a nil if the package isn't found" do - provider.expects(:execute).returns("") + executor.expects(:execute).returns("") @provider.query.should be_nil end it "should return a hash indicating that the package is missing on error" do - provider.expects(:execute).raises(Puppet::ExecutionFailure.new("ERROR!")) + executor.expects(:execute).raises(Puppet::ExecutionFailure.new("ERROR!")) @provider.query.should == { :ensure => :purged, :status => 'missing', :name => @resource[:name], :error => 'ok', } end end describe "when fetching a package list" do it "should query pacman" do provider.expects(:execpipe).with(["/usr/bin/pacman", '-Q']) provider.instances end it "should return installed packages with their versions" do provider.expects(:execpipe).yields(StringIO.new("package1 1.23-4\npackage2 2.00\n")) packages = provider.instances packages.length.should == 2 packages[0].properties.should == { :provider => :pacman, :ensure => '1.23-4', :name => 'package1' } packages[1].properties.should == { :provider => :pacman, :ensure => '2.00', :name => 'package2' } end it "should return nil on error" do provider.expects(:execpipe).raises(Puppet::ExecutionFailure.new("ERROR!")) provider.instances.should be_nil end it "should warn on invalid input" do provider.expects(:execpipe).yields(StringIO.new("blah")) provider.expects(:warning).with("Failed to match line blah") provider.instances.should == [] end end describe "when determining the latest version" do it "should refresh package list" do get_latest_version = sequence("get_latest_version") - provider. + executor. expects(:execute). in_sequence(get_latest_version). - with(['/usr/bin/pacman', '-Sy']) + with(['/usr/bin/pacman', '-Sy'], no_extra_options) - provider. + executor. stubs(:execute). in_sequence(get_latest_version). returns("") @provider.latest end it "should get query pacman for the latest version" do get_latest_version = sequence("get_latest_version") - provider. + executor. stubs(:execute). in_sequence(get_latest_version) - provider. + executor. expects(:execute). in_sequence(get_latest_version). - with(['/usr/bin/pacman', '-Sp', '--print-format', '%v', @resource[:name]]). + with(['/usr/bin/pacman', '-Sp', '--print-format', '%v', @resource[:name]], no_extra_options). returns("") @provider.latest end it "should return the version number from pacman" do - provider. + executor. expects(:execute). at_least_once(). returns("1.00.2-3\n") @provider.latest.should == "1.00.2-3" end end end diff --git a/spec/unit/provider_spec.rb b/spec/unit/provider_spec.rb index 100536d93..a21247fe4 100755 --- a/spec/unit/provider_spec.rb +++ b/spec/unit/provider_spec.rb @@ -1,450 +1,619 @@ #!/usr/bin/env rspec require 'spec_helper' describe Puppet::Provider do before :each do Puppet::Type.newtype(:test) do newparam(:name) { isnamevar } end end after :each do Puppet::Type.rmtype(:test) end let :type do Puppet::Type.type(:test) end let :provider do type.provide(:default) {} end subject { provider } + describe "has command" do + it "installs a method to run the command specified by the path" do + echo_command = expect_command_executed(:echo, "/bin/echo", "an argument") + allow_creation_of(echo_command) + + provider = provider_of do + has_command(:echo, "/bin/echo") + end + + provider.echo("an argument") + end + + it "installs a command that is run with a given environment" do + echo_command = expect_command_executed(:echo, "/bin/echo", "an argument") + allow_creation_of(echo_command, { + :EV => "value", + :OTHER => "different" + }) + + provider = provider_of do + has_command(:echo, "/bin/echo") do + environment :EV => "value", :OTHER => "different" + end + end + + provider.echo("an argument") + end + + it "is required by default" do + provider = provider_of do + has_command(:does_not_exist, "/does/not/exist") + end + + provider.should_not be_suitable + end + + it "is required by default" do + provider = provider_of do + has_command(:does_exist, "/exists/somewhere") + end + + file_exists_and_is_executable("/exists/somewhere") + + provider.should be_suitable + end + + it "can be specified as optional" do + provider = provider_of do + has_command(:does_not_exist, "/does/not/exist") do + is_optional + end + end + + provider.should be_suitable + end + end + + describe "has required commands" do + it "installs methods to run executables by path" do + echo_command = expect_command_executed(:echo, "/bin/echo", "an argument") + ls_command = expect_command_executed(:ls, "/bin/ls") + + allow_creation_of(echo_command) + allow_creation_of(ls_command) + + provider = provider_of do + commands :echo => "/bin/echo", :ls => "/bin/ls" + end + + provider.echo("an argument") + provider.ls + end + + it "allows the provider to be suitable if the executable is present" do + provider = provider_of do + commands :always_exists => "/this/command/exists" + end + + file_exists_and_is_executable("/this/command/exists") + + provider.should be_suitable + end + + it "does not allow the provider to be suitable if the executable is not present" do + provider = provider_of do + commands :does_not_exist => "/this/command/does/not/exist" + end + + provider.should_not be_suitable + end + end + + describe "has optional commands" do + it "installs methods to run executables" do + echo_command = expect_command_executed(:echo, "/bin/echo", "an argument") + ls_command = expect_command_executed(:ls, "/bin/ls") + + allow_creation_of(echo_command) + allow_creation_of(ls_command) + + provider = provider_of do + optional_commands :echo => "/bin/echo", :ls => "/bin/ls" + end + + provider.echo("an argument") + provider.ls + end + + it "allows the provider to be suitable even if the executable is not present" do + provider = provider_of do + optional_commands :does_not_exist => "/this/command/does/not/exist" + end + + provider.should be_suitable + end + end + + it "makes command methods on demand (deprecated)" do + Puppet::Util.expects(:which).with("/not/a/command").returns("/not/a/command") + Puppet::Util::Execution.expects(:execute).with(["/not/a/command"], {}) + + provider = provider_of do + @commands[:echo] = "/not/a/command" + end + provider.stubs(:which).with("/not/a/command").returns("/not/a/command") + + provider.make_command_methods(:echo) + provider.echo + end + + it "should have a specifity class method" do + Puppet::Provider.should respond_to(:specificity) + end + + it "should consider two defaults to be higher specificity than one default" do + one = provider_of do + defaultfor :operatingsystem => "solaris" + end + + two = provider_of do + defaultfor :operatingsystem => "solaris", :operatingsystemrelease => "5.10" + end + + two.specificity.should > one.specificity + end + it "should be Comparable" do res = Puppet::Type.type(:notify).new(:name => "res") # Normally I wouldn't like the stubs, but the only way to name a class # otherwise is to assign it to a constant, and that hurts more here in # testing world. --daniel 2012-01-29 a = Class.new(Puppet::Provider).new(res) a.class.stubs(:name).returns "Puppet::Provider::Notify::A" b = Class.new(Puppet::Provider).new(res) b.class.stubs(:name).returns "Puppet::Provider::Notify::B" c = Class.new(Puppet::Provider).new(res) c.class.stubs(:name).returns "Puppet::Provider::Notify::C" [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]].each do |this| this.sort.should == [a, b, c] end a.should be < b a.should be < c b.should be > a b.should be < c c.should be > a c.should be > b [a, b, c].each {|x| a.should be <= x } [a, b, c].each {|x| c.should be >= x } b.should be_between(a, c) end context "when creating instances" do context "with a resource" do let :resource do type.new(:name => "fred") end subject { provider.new(resource) } it "should set the resource correctly" do subject.resource.must equal resource end it "should set the name from the resource" do subject.name.should == resource.name end end context "with a hash" do subject { provider.new(:name => "fred") } it "should set the name" do subject.name.should == "fred" end it "should not have a resource" do subject.resource.should be_nil end end context "with no arguments" do subject { provider.new } it "should raise an internal error if asked for the name" do expect { subject.name }.to raise_error Puppet::DevError end it "should not have a resource" do subject.resource.should be_nil end end end context "when confining" do it "should be suitable by default" do subject.should be_suitable end it "should not be default by default" do subject.should_not be_default end { { :true => true } => true, { :true => false } => false, { :false => false } => true, { :false => true } => false, { :operatingsystem => Facter.value(:operatingsystem) } => true, { :operatingsystem => :yayness } => false, { :nothing => :yayness } => false, { :exists => Puppet::Util.which("echo") } => true, { :exists => "/this/file/does/not/exist" } => false, { :true => true, :exists => Puppet::Util.which("echo") } => true, { :true => true, :exists => "/this/file/does/not/exist" } => false, { :operatingsystem => Facter.value(:operatingsystem), :exists => Puppet::Util.which("echo") } => true, { :operatingsystem => :yayness, :exists => Puppet::Util.which("echo") } => false, { :operatingsystem => Facter.value(:operatingsystem), :exists => "/this/file/does/not/exist" } => false, { :operatingsystem => :yayness, :exists => "/this/file/does/not/exist" } => false, }.each do |confines, result| it "should confine #{confines.inspect} to #{result}" do confines.each {|test, value| subject.confine test => value } subject.send(result ? :should : :should_not, be_suitable) end end it "should not override a confine even if a second has the same type" do subject.confine :true => false subject.should_not be_suitable subject.confine :true => true subject.should_not be_suitable end it "should not be suitable if any confine fails" do subject.confine :true => false subject.should_not be_suitable 10.times do subject.confine :true => true subject.should_not be_suitable end end end context "default providers" do let :os do Facter.value(:operatingsystem) end it { should respond_to :specificity } it "should find the default provider" do type.provide(:nondefault) {} subject.defaultfor :operatingsystem => os subject.name.should == type.defaultprovider.name end it "should consider any true value enough to be default" do alternate = type.provide(:alternate) {} subject.defaultfor :operatingsystem => [:one, :two, :three, os] subject.name.should == type.defaultprovider.name subject.should be_default alternate.should_not be_default end it "should not be default if the confine doesn't match" do subject.should_not be_default subject.defaultfor :operatingsystem => :one subject.should_not be_default end it "should consider two defaults to be higher specificity than one default" do one = type.provide(:one) do defaultfor :operatingsystem => "solaris" end two = type.provide(:two) do defaultfor :operatingsystem => "solaris", :operatingsystemrelease => "5.10" end two.specificity.should > one.specificity end it "should consider a subclass more specific than its parent class" do parent = type.provide(:parent) child = type.provide(:child, :parent => parent) child.specificity.should > parent.specificity end end context "provider commands" do it "should raise for unknown commands" do expect { subject.command(:something) }.to raise_error Puppet::DevError end it "should handle command inheritance" do parent = type.provide("parent") child = type.provide("child", :parent => parent.name) command = Puppet::Util.which('sh') || Puppet::Util.which('cmd.exe') parent.commands :sh => command FileTest.should be_exists parent.command(:sh) parent.command(:sh).should =~ /#{command}$/ FileTest.should be_exists child.command(:sh) child.command(:sh).should =~ /#{command}$/ end it "#1197: should find commands added in the same run" do subject.commands :testing => "puppet-bug-1197" subject.command(:testing).should be_nil subject.stubs(:which).with("puppet-bug-1197").returns("/puppet-bug-1197") subject.command(:testing).should == "/puppet-bug-1197" # Ideally, we would also test that `suitable?` returned the right thing # here, but it is impossible to get access to the methods that do that # without digging way down into the implementation. --daniel 2012-03-20 end context "with optional commands" do before :each do subject.optional_commands :cmd => "/no/such/binary/exists" end it { should be_suitable } it "should not be suitable if a mandatory command is also missing" do subject.commands :foo => "/no/such/binary/either" subject.should_not be_suitable end it "should define a wrapper for the command" do subject.should respond_to :cmd end it "should return nil if the command is requested" do subject.command(:cmd).should be_nil end it "should raise if the command is invoked" do expect { subject.cmd }.to raise_error Puppet::Error, /Command cmd is missing/ end end end context "mk_resource_methods" do before :each do type.newproperty(:prop1) type.newproperty(:prop2) type.newparam(:param1) type.newparam(:param2) end fields = %w{prop1 prop2 param1 param2} # This is needed for Ruby 1.8.5, which throws an exception that the # default rescue doesn't catch if the method isn't present. Also, it has # no convenient predicate for them, which equally hurts. def has_method?(object, name) begin return true if object.instance_method(name) rescue Exception return false end end fields.each do |name| it "should add getter methods for #{name}" do expect { subject.mk_resource_methods }. to change { has_method?(subject, name) }. from(false).to(true) end it "should add setter methods for #{name}" do method = name + '=' expect { subject.mk_resource_methods }. to change { has_method?(subject, name) }. from(false).to(true) end end context "with an instance" do subject { provider.mk_resource_methods; provider.new(nil) } fields.each do |name| context name do it "should default to :absent" do subject.send(name).should == :absent end it "should update when set" do expect { subject.send(name + '=', "hello") }. to change { subject.send(name) }. from(:absent).to("hello") end end end end end context "source" do it "should default to the provider name" do subject.source.should == :default end it "should default to the provider name for a child provider" do type.provide(:sub, :parent => subject.name).source.should == :sub end it "should override if requested" do provider = type.provide(:sub, :parent => subject.name, :source => subject.source) provider.source.should == subject.source end it "should override to anything you want" do expect { subject.source = :banana }.to change { subject.source }. from(:default).to(:banana) end end context "features" do before :each do type.feature :numeric, '', :methods => [:one, :two] type.feature :alpha, '', :methods => [:a, :b] type.feature :nomethods, '' end { :no => { :alpha => false, :numeric => false, :methods => [] }, :numeric => { :alpha => false, :numeric => true, :methods => [:one, :two] }, :alpha => { :alpha => true, :numeric => false, :methods => [:a, :b] }, :all => { :alpha => true, :numeric => true, :methods => [:a, :b, :one, :two] }, :alpha_and_partial => { :alpha => true, :numeric => false, :methods => [:a, :b, :one] }, :numeric_and_partial => { :alpha => false, :numeric => true, :methods => [:a, :one, :two] }, :all_partial => { :alpha => false, :numeric => false, :methods => [:a, :one] }, :other_and_none => { :alpha => false, :numeric => false, :methods => [:foo, :bar] }, :other_and_alpha => { :alpha => true, :numeric => false, :methods => [:foo, :bar, :a, :b] }, }.each do |name, setup| context "with #{name.to_s.gsub('_', ' ')} features" do let :provider do provider = type.provide(name) setup[:methods].map do |method| provider.send(:define_method, method) do true end end type.provider(name) end let :numeric? do setup[:numeric] ? :should : :should_not end let :alpha? do setup[:alpha] ? :should : :should_not end subject { provider } it { should respond_to :has_features } it { should respond_to :has_feature } context "provider class" do it { should respond_to :nomethods? } it { should_not be_nomethods } it { should respond_to :numeric? } it { subject.send(numeric?, be_numeric) } it { subject.send(numeric?, be_satisfies(:numeric)) } it { should respond_to :alpha? } it { subject.send(alpha?, be_alpha) } it { subject.send(alpha?, be_satisfies(:alpha)) } end context "provider instance" do subject { provider.new } it { should respond_to :numeric? } it { subject.send(numeric?, be_numeric) } it { subject.send(numeric?, be_satisfies(:numeric)) } it { should respond_to :alpha? } it { subject.send(alpha?, be_alpha) } it { subject.send(alpha?, be_satisfies(:alpha)) } end end end context "feature with no methods" do before :each do type.feature :undemanding, '' end it { should respond_to :undemanding? } context "when the feature is not declared" do it { should_not be_undemanding } it { should_not be_satisfies :undemanding } end context "when the feature is declared" do before :each do subject.has_feature :undemanding end it { should be_undemanding } it { should be_satisfies :undemanding } end end context "supports_parameter?" do before :each do type.newparam(:no_feature) type.newparam(:one_feature, :required_features => :alpha) type.newparam(:two_features, :required_features => [:alpha, :numeric]) end let :providers do { :zero => type.provide(:zero), :one => type.provide(:one) do has_features :alpha end, :two => type.provide(:two) do has_features :alpha, :numeric end } end { :zero => { :yes => [:no_feature], :no => [:one_feature, :two_features] }, :one => { :yes => [:no_feature, :one_feature], :no => [:two_features] }, :two => { :yes => [:no_feature, :one_feature, :two_features], :no => [] } }.each do |name, data| data[:yes].each do |param| it "should support #{param} with provider #{name}" do providers[name].should be_supports_parameter param end end data[:no].each do |param| it "should not support #{param} with provider #{name}" do providers[name].should_not be_supports_parameter param end end end end end + + def provider_of(options = {}, &block) + type = Puppet::Type.newtype(:dummy) do + provide(:dummy, options, &block) + end + + type.provider(:dummy) + end + + def expect_command_executed(name, path, *args) + command = Puppet::Provider::Command.new(name, path, Puppet::Util, Puppet::Util::Execution) + command.expects(:execute).with(*args) + command + end + + def allow_creation_of(command, environment = {}) + Puppet::Provider::Command.stubs(:new).with(command.name, command.executable, Puppet::Util, Puppet::Util::Execution, { :custom_environment => environment }).returns(command) + end + + def file_exists_and_is_executable(path) + FileTest.expects(:file?).with(path).returns(true) + FileTest.expects(:executable?).with(path).returns(true) + end end