diff --git a/lib/puppet/indirector/file_bucket_file/file.rb b/lib/puppet/indirector/file_bucket_file/file.rb index 318858aaf..9d9cee793 100644 --- a/lib/puppet/indirector/file_bucket_file/file.rb +++ b/lib/puppet/indirector/file_bucket_file/file.rb @@ -1,146 +1,155 @@ require 'puppet/indirector/code' require 'puppet/file_bucket/file' require 'puppet/util/checksums' require 'fileutils' module Puppet::FileBucketFile class File < Puppet::Indirector::Code include Puppet::Util::Checksums desc "Store files in a directory set based on their checksums." def initialize Puppet.settings.use(:filebucket) end def find( request ) checksum, path = request_to_checksum_and_path( request ) - find_by_checksum( checksum, request.options ) + file = find_by_checksum( checksum, request.options ) + + if file && request.options[:diff_with] + hash_protocol = sumtype(checksum) + file2 = find_by_checksum( "{#{hash_protocol}}#{request.options[:diff_with]}", request.options ) + raise "could not find diff_with #{request.options[:diff_with]}" unless file2 + return `diff #{path_for(file).inspect}/contents #{path_for(file2).inspect}/contents` + end + + file end def save( request ) checksum, path = request_to_checksum_and_path( request ) instance = request.instance instance.checksum = checksum if checksum instance.path = path if path save_to_disk(instance) instance.to_s end private def find_by_checksum( checksum, options ) model.new( nil, :checksum => checksum ) do |bucket_file| bucket_file.bucket_path = options[:bucket_path] filename = contents_path_for( bucket_file ) return nil if ! ::File.exist? filename begin contents = ::File.read filename Puppet.info "FileBucket read #{bucket_file.checksum}" rescue RuntimeError => e raise Puppet::Error, "file could not be read: #{e.message}" end if ::File.exist?(paths_path_for( bucket_file) ) ::File.open(paths_path_for( bucket_file) ) do |f| bucket_file.paths = f.readlines.map { |l| l.chomp } end end bucket_file.contents = contents end end def save_to_disk( bucket_file ) # If the file already exists, just return the md5 sum. if ::File.exist?(contents_path_for( bucket_file) ) verify_identical_file!(bucket_file) else # Make the directories if necessary. unless ::File.directory?( path_for( bucket_file) ) Puppet::Util.withumask(0007) do ::FileUtils.mkdir_p( path_for( bucket_file) ) end end Puppet.info "FileBucket adding #{bucket_file.path} as #{bucket_file.checksum}" # Write the file to disk. Puppet::Util.withumask(0007) do ::File.open(contents_path_for(bucket_file), ::File::WRONLY|::File::CREAT, 0440) do |of| of.print bucket_file.contents end end end save_path_to_paths_file(bucket_file) bucket_file.checksum_data end def request_to_checksum_and_path( request ) return [request.key, nil] if checksum?(request.key) checksum_type, checksum, path = request.key.split(/\//, 3) return(checksum_type.to_s == "" ? nil : [ "{#{checksum_type}}#{checksum}", path ]) end def path_for(bucket_file, subfile = nil) bucket_path = bucket_file.bucket_path || Puppet[:bucketdir] digest = bucket_file.checksum_data dir = ::File.join(digest[0..7].split("")) basedir = ::File.join(bucket_path, dir, digest) return basedir unless subfile ::File.join(basedir, subfile) end def contents_path_for(bucket_file) path_for(bucket_file, "contents") end def paths_path_for(bucket_file) path_for(bucket_file, "paths") end def content_check? true end # If conflict_check is enabled, verify that the passed text is # the same as the text in our file. def verify_identical_file!(bucket_file) return unless content_check? disk_contents = ::File.read(contents_path_for(bucket_file)) # If the contents don't match, then we've found a conflict. # Unlikely, but quite bad. if disk_contents != bucket_file.contents raise Puppet::FileBucket::BucketError, "Got passed new contents for sum #{bucket_file.checksum}", caller else Puppet.info "FileBucket got a duplicate file #{bucket_file.path} (#{bucket_file.checksum})" end end def save_path_to_paths_file(bucket_file) return unless bucket_file.path # check for dupes if ::File.exist?(paths_path_for( bucket_file) ) ::File.open(paths_path_for( bucket_file) ) do |f| return if f.readlines.collect { |l| l.chomp }.include?(bucket_file.path) end end # if it's a new file, or if our path isn't in the file yet, add it ::File.open(paths_path_for(bucket_file), ::File::WRONLY|::File::CREAT|::File::APPEND) do |of| of.puts bucket_file.path end end end end diff --git a/lib/puppet/indirector/indirection.rb b/lib/puppet/indirector/indirection.rb index 309eed7b6..a010c4e40 100644 --- a/lib/puppet/indirector/indirection.rb +++ b/lib/puppet/indirector/indirection.rb @@ -1,308 +1,308 @@ require 'puppet/util/docs' require 'puppet/indirector/envelope' require 'puppet/indirector/request' require 'puppet/util/cacher' # The class that connects functional classes with their different collection # back-ends. Each indirection has a set of associated terminus classes, # each of which is a subclass of Puppet::Indirector::Terminus. class Puppet::Indirector::Indirection include Puppet::Util::Cacher include Puppet::Util::Docs @@indirections = [] # Find an indirection by name. This is provided so that Terminus classes # can specifically hook up with the indirections they are associated with. def self.instance(name) @@indirections.find { |i| i.name == name } end # Return a list of all known indirections. Used to generate the # reference. def self.instances @@indirections.collect { |i| i.name } end # Find an indirected model by name. This is provided so that Terminus classes # can specifically hook up with the indirections they are associated with. def self.model(name) return nil unless match = @@indirections.find { |i| i.name == name } match.model end attr_accessor :name, :model # Create and return our cache terminus. def cache raise(Puppet::DevError, "Tried to cache when no cache class was set") unless cache_class terminus(cache_class) end # Should we use a cache? def cache? cache_class ? true : false end attr_reader :cache_class # Define a terminus class to be used for caching. def cache_class=(class_name) validate_terminus_class(class_name) if class_name @cache_class = class_name end # This is only used for testing. def delete @@indirections.delete(self) if @@indirections.include?(self) end # Set the time-to-live for instances created through this indirection. def ttl=(value) raise ArgumentError, "Indirection TTL must be an integer" unless value.is_a?(Fixnum) @ttl = value end # Default to the runinterval for the ttl. def ttl @ttl ||= Puppet[:runinterval].to_i end # Calculate the expiration date for a returned instance. def expiration Time.now + ttl end # Generate the full doc string. def doc text = "" text += scrub(@doc) + "\n\n" if @doc if s = terminus_setting text += "* **Terminus Setting**: #{terminus_setting}" end text end def initialize(model, name, options = {}) @model = model @name = name @cache_class = nil @terminus_class = nil raise(ArgumentError, "Indirection #{@name} is already defined") if @@indirections.find { |i| i.name == @name } @@indirections << self if mod = options[:extend] extend(mod) options.delete(:extend) end # This is currently only used for cache_class and terminus_class. options.each do |name, value| begin send(name.to_s + "=", value) rescue NoMethodError raise ArgumentError, "#{name} is not a valid Indirection parameter" end end end # Set up our request object. def request(*args) Puppet::Indirector::Request.new(self.name, *args) end # Return the singleton terminus for this indirection. def terminus(terminus_name = nil) # Get the name of the terminus. raise Puppet::DevError, "No terminus specified for #{self.name}; cannot redirect" unless terminus_name ||= terminus_class termini[terminus_name] ||= make_terminus(terminus_name) end # This can be used to select the terminus class. attr_accessor :terminus_setting # Determine the terminus class. def terminus_class unless @terminus_class if setting = self.terminus_setting self.terminus_class = Puppet.settings[setting].to_sym else raise Puppet::DevError, "No terminus class nor terminus setting was provided for indirection #{self.name}" end end @terminus_class end def reset_terminus_class @terminus_class = nil end # Specify the terminus class to use. def terminus_class=(klass) validate_terminus_class(klass) @terminus_class = klass end # This is used by terminus_class= and cache=. def validate_terminus_class(terminus_class) raise ArgumentError, "Invalid terminus name #{terminus_class.inspect}" unless terminus_class and terminus_class.to_s != "" unless Puppet::Indirector::Terminus.terminus_class(self.name, terminus_class) raise ArgumentError, "Could not find terminus #{terminus_class} for indirection #{self.name}" end end # Expire a cached object, if one is cached. Note that we don't actually # remove it, we expire it and write it back out to disk. This way people # can still use the expired object if they want. def expire(key, *args) request = request(:expire, key, *args) return nil unless cache? return nil unless instance = cache.find(request(:find, key, *args)) Puppet.info "Expiring the #{self.name} cache of #{instance.name}" # Set an expiration date in the past instance.expiration = Time.now - 60 cache.save(request(:save, instance, *args)) end # Search for an instance in the appropriate terminus, caching the # results if caching is configured.. def find(key, *args) request = request(:find, key, *args) terminus = prepare(request) begin if result = find_in_cache(request) return result end rescue => detail puts detail.backtrace if Puppet[:trace] Puppet.err "Cached #{self.name} for #{request.key} failed: #{detail}" end # Otherwise, return the result from the terminus, caching if appropriate. if ! request.ignore_terminus? and result = terminus.find(request) - result.expiration ||= self.expiration + result.expiration ||= self.expiration if result.respond_to?(:expiration) if cache? and request.use_cache? Puppet.info "Caching #{self.name} for #{request.key}" cache.save request(:save, result, *args) end return terminus.respond_to?(:filter) ? terminus.filter(result) : result end nil end def find_in_cache(request) # See if our instance is in the cache and up to date. return nil unless cache? and ! request.ignore_cache? and cached = cache.find(request) if cached.expired? Puppet.info "Not using expired #{self.name} for #{request.key} from cache; expired at #{cached.expiration}" return nil end Puppet.debug "Using cached #{self.name} for #{request.key}" cached end # Remove something via the terminus. def destroy(key, *args) request = request(:destroy, key, *args) terminus = prepare(request) result = terminus.destroy(request) if cache? and cached = cache.find(request(:find, key, *args)) # Reuse the existing request, since it's equivalent. cache.destroy(request) end result end # Search for more than one instance. Should always return an array. def search(key, *args) request = request(:search, key, *args) terminus = prepare(request) if result = terminus.search(request) raise Puppet::DevError, "Search results from terminus #{terminus.name} are not an array" unless result.is_a?(Array) result.each do |instance| instance.expiration ||= self.expiration end return result end end # Save the instance in the appropriate terminus. This method is # normally an instance method on the indirected class. def save(key, instance = nil) request = request(:save, key, instance) terminus = prepare(request) result = terminus.save(request) # If caching is enabled, save our document there cache.save(request) if cache? result end private # Check authorization if there's a hook available; fail if there is one # and it returns false. def check_authorization(request, terminus) # At this point, we're assuming authorization makes no sense without # client information. return unless request.node # This is only to authorize via a terminus-specific authorization hook. return unless terminus.respond_to?(:authorized?) unless terminus.authorized?(request) msg = "Not authorized to call #{request.method} on #{request}" msg += " with #{request.options.inspect}" unless request.options.empty? raise ArgumentError, msg end end # Setup a request, pick the appropriate terminus, check the request's authorization, and return it. def prepare(request) # Pick our terminus. if respond_to?(:select_terminus) unless terminus_name = select_terminus(request) raise ArgumentError, "Could not determine appropriate terminus for #{request}" end else terminus_name = terminus_class end dest_terminus = terminus(terminus_name) check_authorization(request, dest_terminus) dest_terminus end # Create a new terminus instance. def make_terminus(terminus_class) # Load our terminus class. unless klass = Puppet::Indirector::Terminus.terminus_class(self.name, terminus_class) raise ArgumentError, "Could not find terminus #{terminus_class} for indirection #{self.name}" end klass.new end # Cache our terminus instances indefinitely, but make it easy to clean them up. cached_attr(:termini) { Hash.new } end diff --git a/lib/puppet/network/http/handler.rb b/lib/puppet/network/http/handler.rb index 61ae2d2fc..f22498b70 100644 --- a/lib/puppet/network/http/handler.rb +++ b/lib/puppet/network/http/handler.rb @@ -1,229 +1,233 @@ module Puppet::Network::HTTP end require 'puppet/network/http/api/v1' require 'puppet/network/rest_authorization' require 'puppet/network/rights' require 'resolv' module Puppet::Network::HTTP::Handler include Puppet::Network::HTTP::API::V1 include Puppet::Network::RestAuthorization attr_reader :server, :handler # Retrieve the accept header from the http request. def accept_header(request) raise NotImplementedError end # Retrieve the Content-Type header from the http request. def content_type_header(request) raise NotImplementedError end # Which format to use when serializing our response or interpreting the request. # IF the client provided a Content-Type use this, otherwise use the Accept header # and just pick the first value. def format_to_use(request) unless header = accept_header(request) raise ArgumentError, "An Accept header must be provided to pick the right format" end format = nil header.split(/,\s*/).each do |name| next unless format = Puppet::Network::FormatHandler.format(name) next unless format.suitable? return format end raise "No specified acceptable formats (#{header}) are functional on this machine" end def request_format(request) if header = content_type_header(request) header.gsub!(/\s*;.*$/,'') # strip any charset format = Puppet::Network::FormatHandler.mime(header) raise "Client sent a mime-type (#{header}) that doesn't correspond to a format we support" if format.nil? return format.name.to_s if format.suitable? end raise "No Content-Type header was received, it isn't possible to unserialize the request" end def format_to_mime(format) format.is_a?(Puppet::Network::Format) ? format.mime : format end def initialize_for_puppet(server) @server = server end # handle an HTTP request def process(request, response) indirection_request = uri2indirection(http_method(request), path(request), params(request)) check_authorization(indirection_request) send("do_#{indirection_request.method}", indirection_request, request, response) rescue SystemExit,NoMemoryError raise rescue Exception => e return do_exception(response, e) end # Set the response up, with the body and status. def set_response(response, body, status = 200) raise NotImplementedError end # Set the specified format as the content type of the response. def set_content_type(response, format) raise NotImplementedError end def do_exception(response, exception, status=400) if exception.is_a?(Puppet::Network::AuthorizationError) # make sure we return the correct status code # for authorization issues status = 403 if status == 400 end if exception.is_a?(Exception) puts exception.backtrace if Puppet[:trace] Puppet.err(exception) end set_content_type(response, "text/plain") set_response(response, exception.to_s, status) end # Execute our find. def do_find(indirection_request, request, response) unless result = indirection_request.model.find(indirection_request.key, indirection_request.to_hash) Puppet.info("Could not find #{indirection_request.indirection_name} for '#{indirection_request.key}'") return do_exception(response, "Could not find #{indirection_request.indirection_name} #{indirection_request.key}", 404) end # The encoding of the result must include the format to use, # and it needs to be used for both the rendering and as # the content type. format = format_to_use(request) set_content_type(response, format) - set_response(response, result.render(format)) + if result.respond_to?(:render) + set_response(response, result.render(format)) + else + set_response(response, result) + end end # Execute our search. def do_search(indirection_request, request, response) result = indirection_request.model.search(indirection_request.key, indirection_request.to_hash) if result.nil? or (result.is_a?(Array) and result.empty?) return do_exception(response, "Could not find instances in #{indirection_request.indirection_name} with '#{indirection_request.key}'", 404) end format = format_to_use(request) set_content_type(response, format) set_response(response, indirection_request.model.render_multiple(format, result)) end # Execute our destroy. def do_destroy(indirection_request, request, response) result = indirection_request.model.destroy(indirection_request.key, indirection_request.to_hash) return_yaml_response(response, result) end # Execute our save. def do_save(indirection_request, request, response) data = body(request).to_s raise ArgumentError, "No data to save" if !data or data.empty? format = request_format(request) obj = indirection_request.model.convert_from(format, data) result = save_object(indirection_request, obj) return_yaml_response(response, result) end # resolve node name from peer's ip address # this is used when the request is unauthenticated def resolve_node(result) begin return Resolv.getname(result[:ip]) rescue => detail Puppet.err "Could not resolve #{result[:ip]}: #{detail}" end result[:ip] end private def return_yaml_response(response, body) set_content_type(response, Puppet::Network::FormatHandler.format("yaml")) set_response(response, body.to_yaml) end # LAK:NOTE This has to be here for testing; it's a stub-point so # we keep infinite recursion from happening. def save_object(ind_request, object) object.save(ind_request.key) end def get?(request) http_method(request) == 'GET' end def put?(request) http_method(request) == 'PUT' end def delete?(request) http_method(request) == 'DELETE' end # methods to be overridden by the including web server class def http_method(request) raise NotImplementedError end def path(request) raise NotImplementedError end def request_key(request) raise NotImplementedError end def body(request) raise NotImplementedError end def params(request) raise NotImplementedError end def decode_params(params) params.inject({}) do |result, ary| param, value = ary next result if param.nil? || param.empty? param = param.to_sym # These shouldn't be allowed to be set by clients # in the query string, for security reasons. next result if param == :node next result if param == :ip value = CGI.unescape(value) if value =~ /^---/ value = YAML.load(value) else value = true if value == "true" value = false if value == "false" value = Integer(value) if value =~ /^\d+$/ value = value.to_f if value =~ /^\d+\.\d+$/ end result[param] = value result end end end diff --git a/spec/unit/indirector/file_bucket_file/file_spec.rb b/spec/unit/indirector/file_bucket_file/file_spec.rb index aa3ade6b6..7bf02b5b3 100755 --- a/spec/unit/indirector/file_bucket_file/file_spec.rb +++ b/spec/unit/indirector/file_bucket_file/file_spec.rb @@ -1,290 +1,335 @@ #!/usr/bin/env ruby require ::File.dirname(__FILE__) + '/../../../spec_helper' require 'puppet/indirector/file_bucket_file/file' describe Puppet::FileBucketFile::File do it "should be a subclass of the Code terminus class" do Puppet::FileBucketFile::File.superclass.should equal(Puppet::Indirector::Code) end it "should have documentation" do Puppet::FileBucketFile::File.doc.should be_instance_of(String) end + describe "non-stubbing tests" do + include PuppetSpec::Files + + before do + Puppet[:bucketdir] = tmpdir('bucketdir') + end + + describe "when diffing files" do + def save_bucket_file(contents) + bucket_file = Puppet::FileBucket::File.new(contents) + bucket_file.save + bucket_file.checksum_data + end + + it "should generate an empty string if there is no diff" do + checksum = save_bucket_file("I'm the contents of a file") + Puppet::FileBucket::File.find("md5/#{checksum}", :diff_with => checksum).should == '' + end + + it "should generate a proper diff if there is a diff" do + checksum1 = save_bucket_file("foo\nbar\nbaz") + checksum2 = save_bucket_file("foo\nbiz\nbaz") + diff = Puppet::FileBucket::File.find("md5/#{checksum1}", :diff_with => checksum2) + diff.should == < biz +HERE + end + + it "should raise an exception if the hash to diff against isn't found" do + checksum = save_bucket_file("whatever") + bogus_checksum = "d1bf072d0e2c6e20e3fbd23f022089a1" + lambda { Puppet::FileBucket::File.find("md5/#{checksum}", :diff_with => bogus_checksum) }.should raise_error "could not find diff_with #{bogus_checksum}" + end + + it "should return nil if the hash to diff from isn't found" do + checksum = save_bucket_file("whatever") + bogus_checksum = "d1bf072d0e2c6e20e3fbd23f022089a1" + Puppet::FileBucket::File.find("md5/#{bogus_checksum}", :diff_with => checksum).should == nil + end + end + end + describe "when initializing" do it "should use the filebucket settings section" do Puppet.settings.expects(:use).with(:filebucket) Puppet::FileBucketFile::File.new end end describe "the find_by_checksum method" do before do # this is the default from spec_helper, but it keeps getting reset at odd times Puppet[:bucketdir] = "/dev/null/bucket" @digest = "4a8ec4fa5f01b4ab1a0ab8cbccb709f0" @checksum = "{md5}4a8ec4fa5f01b4ab1a0ab8cbccb709f0" @dir = '/dev/null/bucket/4/a/8/e/c/4/f/a/4a8ec4fa5f01b4ab1a0ab8cbccb709f0' @contents = "file contents" end it "should return nil if a file doesn't exist" do ::File.expects(:exist?).with("#{@dir}/contents").returns false bucketfile = Puppet::FileBucketFile::File.new.send(:find_by_checksum, "{md5}#{@digest}", {}) bucketfile.should == nil end it "should find a filebucket if the file exists" do ::File.expects(:exist?).with("#{@dir}/contents").returns true ::File.expects(:exist?).with("#{@dir}/paths").returns false ::File.expects(:read).with("#{@dir}/contents").returns @contents bucketfile = Puppet::FileBucketFile::File.new.send(:find_by_checksum, "{md5}#{@digest}", {}) bucketfile.should_not == nil end it "should load the paths" do paths = ["path1", "path2"] ::File.expects(:exist?).with("#{@dir}/contents").returns true ::File.expects(:exist?).with("#{@dir}/paths").returns true ::File.expects(:read).with("#{@dir}/contents").returns @contents mockfile = mock "file" mockfile.expects(:readlines).returns( paths ) ::File.expects(:open).with("#{@dir}/paths").yields mockfile Puppet::FileBucketFile::File.new.send(:find_by_checksum, "{md5}#{@digest}", {}).paths.should == paths end end describe "when retrieving files" do before :each do Puppet.settings.stubs(:use) @store = Puppet::FileBucketFile::File.new @digest = "70924d6fa4b2d745185fa4660703a5c0" @sum = stub 'sum', :name => @digest @dir = "/what/ever" Puppet.stubs(:[]).with(:bucketdir).returns(@dir) @contents_path = '/what/ever/7/0/9/2/4/d/6/f/70924d6fa4b2d745185fa4660703a5c0/contents' @paths_path = '/what/ever/7/0/9/2/4/d/6/f/70924d6fa4b2d745185fa4660703a5c0/paths' @request = stub 'request', :key => "md5/#{@digest}/remote/path", :options => {} end it "should call find_by_checksum" do @store.expects(:find_by_checksum).with{|x,opts| x == "{md5}#{@digest}"}.returns(false) @store.find(@request) end it "should look for the calculated path" do ::File.expects(:exist?).with(@contents_path).returns(false) @store.find(@request) end it "should return an instance of Puppet::FileBucket::File created with the content if the file exists" do content = "my content" bucketfile = stub 'bucketfile' bucketfile.stubs(:bucket_path) bucketfile.stubs(:bucket_path=) bucketfile.stubs(:checksum_data).returns(@digest) bucketfile.stubs(:checksum).returns(@checksum) bucketfile.expects(:contents=).with(content) Puppet::FileBucket::File.expects(:new).with(nil, {:checksum => "{md5}#{@digest}"}).yields(bucketfile).returns(bucketfile) ::File.expects(:exist?).with(@contents_path).returns(true) ::File.expects(:exist?).with(@paths_path).returns(false) ::File.expects(:read).with(@contents_path).returns(content) @store.find(@request).should equal(bucketfile) end it "should return nil if no file is found" do ::File.expects(:exist?).with(@contents_path).returns(false) @store.find(@request).should be_nil end it "should fail intelligently if a found file cannot be read" do ::File.expects(:exist?).with(@contents_path).returns(true) ::File.expects(:read).with(@contents_path).raises(RuntimeError) proc { @store.find(@request) }.should raise_error(Puppet::Error) end end describe "when determining file paths" do before do Puppet[:bucketdir] = '/dev/null/bucketdir' @digest = 'DEADBEEFC0FFEE' @bucket = stub_everything "bucket" @bucket.expects(:checksum_data).returns(@digest) end it "should use the value of the :bucketdir setting as the root directory" do path = Puppet::FileBucketFile::File.new.send(:contents_path_for, @bucket) path.should =~ %r{^/dev/null/bucketdir} end it "should choose a path 8 directories deep with each directory name being the respective character in the filebucket" do path = Puppet::FileBucketFile::File.new.send(:contents_path_for, @bucket) dirs = @digest[0..7].split("").join(File::SEPARATOR) path.should be_include(dirs) end it "should use the full filebucket as the final directory name" do path = Puppet::FileBucketFile::File.new.send(:contents_path_for, @bucket) ::File.basename(::File.dirname(path)).should == @digest end it "should use 'contents' as the actual file name" do path = Puppet::FileBucketFile::File.new.send(:contents_path_for, @bucket) ::File.basename(path).should == "contents" end it "should use the bucketdir, the 8 sum character directories, the full filebucket, and 'contents' as the full file name" do path = Puppet::FileBucketFile::File.new.send(:contents_path_for, @bucket) path.should == ['/dev/null/bucketdir', @digest[0..7].split(""), @digest, "contents"].flatten.join(::File::SEPARATOR) end end describe "when saving files" do before do # this is the default from spec_helper, but it keeps getting reset at odd times Puppet[:bucketdir] = "/dev/null/bucket" @digest = "4a8ec4fa5f01b4ab1a0ab8cbccb709f0" @checksum = "{md5}4a8ec4fa5f01b4ab1a0ab8cbccb709f0" @dir = '/dev/null/bucket/4/a/8/e/c/4/f/a/4a8ec4fa5f01b4ab1a0ab8cbccb709f0' @contents = "file contents" @bucket = stub "bucket file" @bucket.stubs(:bucket_path) @bucket.stubs(:checksum_data).returns(@digest) @bucket.stubs(:path).returns(nil) @bucket.stubs(:checksum).returns(nil) @bucket.stubs(:contents).returns("file contents") end it "should save the contents to the calculated path" do ::File.stubs(:directory?).with(@dir).returns(true) ::File.expects(:exist?).with("#{@dir}/contents").returns false mockfile = mock "file" mockfile.expects(:print).with(@contents) ::File.expects(:open).with("#{@dir}/contents", ::File::WRONLY|::File::CREAT, 0440).yields(mockfile) Puppet::FileBucketFile::File.new.send(:save_to_disk, @bucket) end it "should make any directories necessary for storage" do FileUtils.expects(:mkdir_p).with do |arg| ::File.umask == 0007 and arg == @dir end ::File.expects(:directory?).with(@dir).returns(false) ::File.expects(:open).with("#{@dir}/contents", ::File::WRONLY|::File::CREAT, 0440) ::File.expects(:exist?).with("#{@dir}/contents").returns false Puppet::FileBucketFile::File.new.send(:save_to_disk, @bucket) end end describe "when verifying identical files" do before do # this is the default from spec_helper, but it keeps getting reset at odd times Puppet[:bucketdir] = "/dev/null/bucket" @digest = "4a8ec4fa5f01b4ab1a0ab8cbccb709f0" @checksum = "{md5}4a8ec4fa5f01b4ab1a0ab8cbccb709f0" @dir = '/dev/null/bucket/4/a/8/e/c/4/f/a/4a8ec4fa5f01b4ab1a0ab8cbccb709f0' @contents = "file contents" @bucket = stub "bucket file" @bucket.stubs(:bucket_path) @bucket.stubs(:checksum).returns(@checksum) @bucket.stubs(:checksum_data).returns(@digest) @bucket.stubs(:path).returns(nil) @bucket.stubs(:contents).returns("file contents") end it "should raise an error if the files don't match" do File.expects(:read).with("#{@dir}/contents").returns("corrupt contents") lambda{ Puppet::FileBucketFile::File.new.send(:verify_identical_file!, @bucket) }.should raise_error(Puppet::FileBucket::BucketError) end it "should do nothing if the files match" do File.expects(:read).with("#{@dir}/contents").returns("file contents") Puppet::FileBucketFile::File.new.send(:verify_identical_file!, @bucket) end end describe "when writing to the paths file" do before do Puppet[:bucketdir] = '/dev/null/bucketdir' @digest = '70924d6fa4b2d745185fa4660703a5c0' @bucket = stub_everything "bucket" @paths_path = '/dev/null/bucketdir/7/0/9/2/4/d/6/f/70924d6fa4b2d745185fa4660703a5c0/paths' @paths = [] @bucket.stubs(:paths).returns(@paths) @bucket.stubs(:checksum_data).returns(@digest) end it "should create a file if it doesn't exist" do @bucket.expects(:path).returns('path/to/save').at_least_once File.expects(:exist?).with(@paths_path).returns(false) file = stub "file" file.expects(:puts).with('path/to/save') File.expects(:open).with(@paths_path, ::File::WRONLY|::File::CREAT|::File::APPEND).yields(file) Puppet::FileBucketFile::File.new.send(:save_path_to_paths_file, @bucket) end it "should append to a file if it exists" do @bucket.expects(:path).returns('path/to/save').at_least_once File.expects(:exist?).with(@paths_path).returns(true) old_file = stub "file" old_file.stubs(:readlines).returns [] File.expects(:open).with(@paths_path).yields(old_file) file = stub "file" file.expects(:puts).with('path/to/save') File.expects(:open).with(@paths_path, ::File::WRONLY|::File::CREAT|::File::APPEND).yields(file) Puppet::FileBucketFile::File.new.send(:save_path_to_paths_file, @bucket) end it "should not alter a file if it already contains the path" do @bucket.expects(:path).returns('path/to/save').at_least_once File.expects(:exist?).with(@paths_path).returns(true) old_file = stub "file" old_file.stubs(:readlines).returns ["path/to/save\n"] File.expects(:open).with(@paths_path).yields(old_file) Puppet::FileBucketFile::File.new.send(:save_path_to_paths_file, @bucket) end it "should do nothing if there is no path" do @bucket.expects(:path).returns(nil).at_least_once Puppet::FileBucketFile::File.new.send(:save_path_to_paths_file, @bucket) end end end diff --git a/spec/unit/network/http/handler_spec.rb b/spec/unit/network/http/handler_spec.rb index 76a9c5530..cdbce41f7 100755 --- a/spec/unit/network/http/handler_spec.rb +++ b/spec/unit/network/http/handler_spec.rb @@ -1,455 +1,461 @@ #!/usr/bin/env ruby require File.dirname(__FILE__) + '/../../../spec_helper' require 'puppet/network/http/handler' require 'puppet/network/rest_authorization' class HttpHandled include Puppet::Network::HTTP::Handler end describe Puppet::Network::HTTP::Handler do before do @handler = HttpHandled.new end it "should include the v1 REST API" do Puppet::Network::HTTP::Handler.ancestors.should be_include(Puppet::Network::HTTP::API::V1) end it "should include the Rest Authorization system" do Puppet::Network::HTTP::Handler.ancestors.should be_include(Puppet::Network::RestAuthorization) end it "should have a method for initializing" do @handler.should respond_to(:initialize_for_puppet) end describe "when initializing" do it "should fail when no server type has been provided" do lambda { @handler.initialize_for_puppet }.should raise_error(ArgumentError) end it "should set server type" do @handler.initialize_for_puppet("foo") @handler.server.should == "foo" end end it "should be able to process requests" do @handler.should respond_to(:process) end describe "when processing a request" do before do @request = stub('http request') @request.stubs(:[]).returns "foo" @response = stub('http response') @model_class = stub('indirected model class') @result = stub 'result', :render => "mytext" @handler.stubs(:check_authorization) stub_server_interface end # Stub out the interface we require our including classes to # implement. def stub_server_interface @handler.stubs(:accept_header ).returns "format_one,format_two" @handler.stubs(:content_type_header).returns "text/yaml" @handler.stubs(:set_content_type ).returns "my_result" @handler.stubs(:set_response ).returns "my_result" @handler.stubs(:path ).returns "/my_handler/my_result" @handler.stubs(:http_method ).returns("GET") @handler.stubs(:params ).returns({}) @handler.stubs(:content_type ).returns("text/plain") end it "should create an indirection request from the path, parameters, and http method" do @handler.expects(:path).with(@request).returns "mypath" @handler.expects(:http_method).with(@request).returns "mymethod" @handler.expects(:params).with(@request).returns "myparams" @handler.expects(:uri2indirection).with("mymethod", "mypath", "myparams").returns stub("request", :method => :find) @handler.stubs(:do_find) @handler.process(@request, @response) end it "should call the 'do' method associated with the indirection method" do request = stub 'request' @handler.expects(:uri2indirection).returns request request.expects(:method).returns "mymethod" @handler.expects(:do_mymethod).with(request, @request, @response) @handler.process(@request, @response) end it "should delegate authorization to the RestAuthorization layer" do request = stub 'request' @handler.expects(:uri2indirection).returns request request.expects(:method).returns "mymethod" @handler.expects(:do_mymethod).with(request, @request, @response) @handler.expects(:check_authorization).with(request) @handler.process(@request, @response) end it "should return 403 if the request is not authorized" do request = stub 'request' @handler.expects(:uri2indirection).returns request @handler.expects(:do_mymethod).never @handler.expects(:check_authorization).with(request).raises(Puppet::Network::AuthorizationError.new("forbindden")) @handler.expects(:set_response).with { |response, body, status| status == 403 } @handler.process(@request, @response) end it "should serialize a controller exception when an exception is thrown while finding the model instance" do @handler.expects(:uri2indirection).returns stub("request", :method => :find) @handler.expects(:do_find).raises(ArgumentError, "The exception") @handler.expects(:set_response).with { |response, body, status| body == "The exception" and status == 400 } @handler.process(@request, @response) end it "should set the format to text/plain when serializing an exception" do @handler.expects(:set_content_type).with(@response, "text/plain") @handler.do_exception(@response, "A test", 404) end it "should raise an error if the request is formatted in an unknown format" do @handler.stubs(:content_type_header).returns "unknown format" lambda { @handler.request_format(@request) }.should raise_error end it "should still find the correct format if content type contains charset information" do @handler.stubs(:content_type_header).returns "text/plain; charset=UTF-8" @handler.request_format(@request).should == "s" end describe "when finding a model instance" do before do @irequest = stub 'indirection_request', :method => :find, :indirection_name => "my_handler", :to_hash => {}, :key => "my_result", :model => @model_class @model_class.stubs(:find).returns @result @format = stub 'format', :suitable? => true, :mime => "text/format", :name => "format" Puppet::Network::FormatHandler.stubs(:format).returns @format @oneformat = stub 'one', :suitable? => true, :mime => "text/one", :name => "one" Puppet::Network::FormatHandler.stubs(:format).with("one").returns @oneformat end it "should use the indirection request to find the model class" do @irequest.expects(:model).returns @model_class @handler.do_find(@irequest, @request, @response) end it "should use the escaped request key" do @model_class.expects(:find).with do |key, args| key == "my_result" end.returns @result @handler.do_find(@irequest, @request, @response) end it "should use a common method for determining the request parameters" do @irequest.stubs(:to_hash).returns(:foo => :baz, :bar => :xyzzy) @model_class.expects(:find).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end.returns @result @handler.do_find(@irequest, @request, @response) end it "should set the content type to the first format specified in the accept header" do @handler.expects(:accept_header).with(@request).returns "one,two" @handler.expects(:set_content_type).with(@response, @oneformat) @handler.do_find(@irequest, @request, @response) end it "should fail if no accept header is provided" do @handler.expects(:accept_header).with(@request).returns nil lambda { @handler.do_find(@irequest, @request, @response) }.should raise_error(ArgumentError) end it "should fail if the accept header does not contain a valid format" do @handler.expects(:accept_header).with(@request).returns "" lambda { @handler.do_find(@irequest, @request, @response) }.should raise_error(RuntimeError) end it "should not use an unsuitable format" do @handler.expects(:accept_header).with(@request).returns "foo,bar" foo = mock 'foo', :suitable? => false bar = mock 'bar', :suitable? => true Puppet::Network::FormatHandler.expects(:format).with("foo").returns foo Puppet::Network::FormatHandler.expects(:format).with("bar").returns bar @handler.expects(:set_content_type).with(@response, bar) # the suitable one @handler.do_find(@irequest, @request, @response) end it "should render the result using the first format specified in the accept header" do @handler.expects(:accept_header).with(@request).returns "one,two" @result.expects(:render).with(@oneformat) @handler.do_find(@irequest, @request, @response) end + it "should pass the result through without rendering it if the result is a string" do + @model_class.stubs(:find).returns "foo" + @handler.expects(:set_response).with(@response, "foo") + @handler.do_find(@irequest, @request, @response) + end + it "should use the default status when a model find call succeeds" do @handler.expects(:set_response).with { |response, body, status| status.nil? } @handler.do_find(@irequest, @request, @response) end it "should return a serialized object when a model find call succeeds" do @model_instance = stub('model instance') @model_instance.expects(:render).returns "my_rendered_object" @handler.expects(:set_response).with { |response, body, status| body == "my_rendered_object" } @model_class.stubs(:find).returns(@model_instance) @handler.do_find(@irequest, @request, @response) end it "should return a 404 when no model instance can be found" do @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @model_class.stubs(:find).returns(nil) @handler.do_find(@irequest, @request, @response) end it "should write a log message when no model instance can be found" do @model_class.stubs(:name).returns "my name" @model_class.stubs(:find).returns(nil) Puppet.expects(:info).with("Could not find my_handler for 'my_result'") @handler.do_find(@irequest, @request, @response) end it "should serialize the result in with the appropriate format" do @model_instance = stub('model instance') @handler.expects(:format_to_use).returns(@oneformat) @model_instance.expects(:render).with(@oneformat).returns "my_rendered_object" @model_class.stubs(:find).returns(@model_instance) @handler.do_find(@irequest, @request, @response) end end describe "when searching for model instances" do before do @irequest = stub 'indirection_request', :method => :find, :indirection_name => "my_handler", :to_hash => {}, :key => "key", :model => @model_class @result1 = mock 'result1' @result2 = mock 'results' @result = [@result1, @result2] @model_class.stubs(:render_multiple).returns "my rendered instances" @model_class.stubs(:search).returns(@result) @format = stub 'format', :suitable? => true, :mime => "text/format", :name => "format" Puppet::Network::FormatHandler.stubs(:format).returns @format @oneformat = stub 'one', :suitable? => true, :mime => "text/one", :name => "one" Puppet::Network::FormatHandler.stubs(:format).with("one").returns @oneformat end it "should use the indirection request to find the model" do @irequest.expects(:model).returns @model_class @handler.do_search(@irequest, @request, @response) end it "should use a common method for determining the request parameters" do @irequest.stubs(:to_hash).returns(:foo => :baz, :bar => :xyzzy) @model_class.expects(:search).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end.returns @result @handler.do_search(@irequest, @request, @response) end it "should use the default status when a model search call succeeds" do @model_class.stubs(:search).returns(@result) @handler.do_search(@irequest, @request, @response) end it "should set the content type to the first format returned by the accept header" do @handler.expects(:accept_header).with(@request).returns "one,two" @handler.expects(:set_content_type).with(@response, @oneformat) @handler.do_search(@irequest, @request, @response) end it "should return a list of serialized objects when a model search call succeeds" do @handler.expects(:accept_header).with(@request).returns "one,two" @model_class.stubs(:search).returns(@result) @model_class.expects(:render_multiple).with(@oneformat, @result).returns "my rendered instances" @handler.expects(:set_response).with { |response, data| data == "my rendered instances" } @handler.do_search(@irequest, @request, @response) end it "should return a 404 when searching returns an empty array" do @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @model_class.stubs(:search).returns([]) @handler.do_search(@irequest, @request, @response) end it "should return a 404 when searching returns nil" do @model_class.stubs(:name).returns "my name" @handler.expects(:set_response).with { |response, body, status| status == 404 } @model_class.stubs(:search).returns([]) @handler.do_search(@irequest, @request, @response) end end describe "when destroying a model instance" do before do @irequest = stub 'indirection_request', :method => :destroy, :indirection_name => "my_handler", :to_hash => {}, :key => "key", :model => @model_class @result = stub 'result', :render => "the result" @model_class.stubs(:destroy).returns @result end it "should use the indirection request to find the model" do @irequest.expects(:model).returns @model_class @handler.do_destroy(@irequest, @request, @response) end it "should use the escaped request key to destroy the instance in the model" do @irequest.expects(:key).returns "foo bar" @model_class.expects(:destroy).with do |key, args| key == "foo bar" end @handler.do_destroy(@irequest, @request, @response) end it "should use a common method for determining the request parameters" do @irequest.stubs(:to_hash).returns(:foo => :baz, :bar => :xyzzy) @model_class.expects(:destroy).with do |key, args| args[:foo] == :baz and args[:bar] == :xyzzy end @handler.do_destroy(@irequest, @request, @response) end it "should use the default status code a model destroy call succeeds" do @handler.expects(:set_response).with { |response, body, status| status.nil? } @handler.do_destroy(@irequest, @request, @response) end it "should return a yaml-encoded result when a model destroy call succeeds" do @result = stub 'result', :to_yaml => "the result" @model_class.expects(:destroy).returns(@result) @handler.expects(:set_response).with { |response, body, status| body == "the result" } @handler.do_destroy(@irequest, @request, @response) end end describe "when saving a model instance" do before do @irequest = stub 'indirection_request', :method => :save, :indirection_name => "my_handler", :to_hash => {}, :key => "key", :model => @model_class @handler.stubs(:body).returns('my stuff') @handler.stubs(:content_type_header).returns("text/yaml") @result = stub 'result', :render => "the result" @model_instance = stub('indirected model instance', :save => true) @model_class.stubs(:convert_from).returns(@model_instance) @format = stub 'format', :suitable? => true, :name => "format", :mime => "text/format" Puppet::Network::FormatHandler.stubs(:format).returns @format @yamlformat = stub 'yaml', :suitable? => true, :name => "yaml", :mime => "text/yaml" Puppet::Network::FormatHandler.stubs(:format).with("yaml").returns @yamlformat end it "should use the indirection request to find the model" do @irequest.expects(:model).returns @model_class @handler.do_save(@irequest, @request, @response) end it "should use the 'body' hook to retrieve the body of the request" do @handler.expects(:body).returns "my body" @model_class.expects(:convert_from).with { |format, body| body == "my body" }.returns @model_instance @handler.do_save(@irequest, @request, @response) end it "should fail to save model if data is not specified" do @handler.stubs(:body).returns('') lambda { @handler.do_save(@irequest, @request, @response) }.should raise_error(ArgumentError) end it "should use a common method for determining the request parameters" do @model_instance.expects(:save).with('key').once @handler.do_save(@irequest, @request, @response) end it "should use the default status when a model save call succeeds" do @handler.expects(:set_response).with { |response, body, status| status.nil? } @handler.do_save(@irequest, @request, @response) end it "should return the yaml-serialized result when a model save call succeeds" do @model_instance.stubs(:save).returns(@model_instance) @model_instance.expects(:to_yaml).returns('foo') @handler.do_save(@irequest, @request, @response) end it "should set the content to yaml" do @handler.expects(:set_content_type).with(@response, @yamlformat) @handler.do_save(@irequest, @request, @response) end it "should use the content-type header to know the body format" do @handler.expects(:content_type_header).returns("text/format") Puppet::Network::FormatHandler.stubs(:mime).with("text/format").returns @format @model_class.expects(:convert_from).with { |format, body| format == "format" }.returns @model_instance @handler.do_save(@irequest, @request, @response) end end end describe "when resolving node" do it "should use a look-up from the ip address" do Resolv.expects(:getname).with("1.2.3.4").returns("host.domain.com") @handler.resolve_node(:ip => "1.2.3.4") end it "should return the look-up result" do Resolv.stubs(:getname).with("1.2.3.4").returns("host.domain.com") @handler.resolve_node(:ip => "1.2.3.4").should == "host.domain.com" end it "should return the ip address if resolving fails" do Resolv.stubs(:getname).with("1.2.3.4").raises(RuntimeError, "no such host") @handler.resolve_node(:ip => "1.2.3.4").should == "1.2.3.4" end end end