diff --git a/acceptance/tests/allow_arbitrary_node_name_fact_for_agent.rb b/acceptance/tests/allow_arbitrary_node_name_fact_for_agent.rb index f6a9287c0..c2b25f649 100644 --- a/acceptance/tests/allow_arbitrary_node_name_fact_for_agent.rb +++ b/acceptance/tests/allow_arbitrary_node_name_fact_for_agent.rb @@ -1,88 +1,88 @@ test_name "node_name_fact should be used to determine the node name for puppet agent" success_message = "node_name_fact setting was correctly used to determine the node name" testdir = master.tmpdir("nodenamefact") node_names = [] on agents, facter('kernel') do node_names << stdout.chomp end node_names.uniq! authfile = "#{testdir}/auth.conf" authconf = node_names.map do |node_name| %Q[ -path /catalog/#{node_name} +path /v3/catalog/#{node_name} auth yes allow * -path /node/#{node_name} +path /v3/node/#{node_name} auth yes allow * -path /report/#{node_name} +path /v3/report/#{node_name} auth yes allow * ] end.join("\n") manifest_file = "#{testdir}/environments/production/manifests/manifest.pp" manifest = %Q[ Exec { path => "/usr/bin:/bin" } node default { notify { "false": } } ] manifest << node_names.map do |node_name| %Q[ node "#{node_name}" { notify { "#{success_message}": } } ] end.join("\n") apply_manifest_on(master, <<-MANIFEST, :catch_failures => true) File { ensure => directory, mode => '0777', } file { '#{testdir}':; '#{testdir}/environments':; '#{testdir}/environments/production':; '#{testdir}/environments/production/manifests':; } file { '#{manifest_file}': ensure => file, mode => '0644', content => '#{manifest}', } file { '#{authfile}': ensure => file, mode => '0644', content => '#{authconf}', } MANIFEST with_these_opts = { 'main' => { 'environmentpath' => "#{testdir}/environments", }, 'master' => { 'rest_authconfig' => "#{testdir}/auth.conf", 'node_terminus' => 'plain', }, } with_puppet_running_on master, with_these_opts, testdir do on(agents, puppet('agent', "--no-daemonize --verbose --onetime --node_name_fact kernel --server #{master}")) do assert_match(/defined 'message'.*#{success_message}/, stdout) end end diff --git a/acceptance/tests/allow_arbitrary_node_name_for_agent.rb b/acceptance/tests/allow_arbitrary_node_name_for_agent.rb index 16cf169e6..06fb42f0b 100644 --- a/acceptance/tests/allow_arbitrary_node_name_for_agent.rb +++ b/acceptance/tests/allow_arbitrary_node_name_for_agent.rb @@ -1,74 +1,74 @@ test_name "node_name_value should be used as the node name for puppet agent" success_message = "node_name_value setting was correctly used as the node name" in_testdir = master.tmpdir('nodenamevalue') authfile = "#{in_testdir}/auth.conf" authconf = <<-AUTHCONF -path /catalog/specified_node_name +path /v3/catalog/specified_node_name auth yes allow * -path /node/specified_node_name +path /v3/node/specified_node_name auth yes allow * -path /report/specified_node_name +path /v3/report/specified_node_name auth yes allow * AUTHCONF manifest_file = "#{in_testdir}/environments/production/manifests/manifest.pp" manifest = <<-MANIFEST Exec { path => "/usr/bin:/bin" } node default { notify { "false": } } node specified_node_name { notify { "#{success_message}": } } MANIFEST apply_manifest_on(master, <<-MANIFEST, :catch_failures => true) File { ensure => directory, mode => '0777', } file { '#{in_testdir}':; '#{in_testdir}/environments':; '#{in_testdir}/environments/production':; '#{in_testdir}/environments/production/manifests':; } file { '#{manifest_file}': ensure => file, mode => '0644', content => '#{manifest}', } file { '#{authfile}': ensure => file, mode => '0644', content => '#{authconf}', } MANIFEST with_these_opts = { 'main' => { 'environmentpath' => "#{in_testdir}/environments", }, 'master' => { 'rest_authconfig' => "#{in_testdir}/auth.conf", 'node_terminus' => 'plain', }, } with_puppet_running_on master, with_these_opts, in_testdir do on(agents, puppet('agent', "-t --node_name_value specified_node_name --server #{master}"), :acceptable_exit_codes => [0,2]) do assert_match(/defined 'message'.*#{success_message}/, stdout) end end diff --git a/acceptance/tests/concurrency/ticket_2659_concurrent_catalog_requests.rb b/acceptance/tests/concurrency/ticket_2659_concurrent_catalog_requests.rb index 767d26853..6e3af0066 100644 --- a/acceptance/tests/concurrency/ticket_2659_concurrent_catalog_requests.rb +++ b/acceptance/tests/concurrency/ticket_2659_concurrent_catalog_requests.rb @@ -1,110 +1,110 @@ test_name "concurrent catalog requests (PUP-2659)" # we're only testing the effects of loading a master with concurrent requests confine :except, :platform => 'windows' step "setup a manifest" testdir = master.tmpdir("concurrent") apply_manifest_on(master, <<-MANIFEST, :catch_failures => true) File { ensure => directory, owner => #{master.puppet['user']}, group => #{master.puppet['group']}, mode => '750', } file { '#{testdir}': } file { '#{testdir}/busy': } file { '#{testdir}/busy/one.txt': ensure => file, mode => '640', content => "Something to read", } file { '#{testdir}/busy/two.txt': ensure => file, mode => '640', content => "Something else to read", } file { '#{testdir}/busy/three.txt': ensure => file, mode => '640', content => "Something more else to read", } file { '#{testdir}/environments': } file { '#{testdir}/environments/production': } file { '#{testdir}/environments/production/manifests': } file { '#{testdir}/environments/production/manifests/site.pp': ensure => file, content => ' $foo = inline_template(" <%- 1000.times do Dir.glob(\\'#{testdir}/busy/*.txt\\').each do |f| File.read(f) end end %> \\'touched the file system for a bit\\' ") notify { "end": message => $foo, } ', mode => '640', } MANIFEST step "start master" master_opts = { 'main' => { 'environmentpath' => "#{testdir}/environments", } } with_puppet_running_on(master, master_opts, testdir) do step "concurrent catalog curls (with alliterative alacrity)" agents.each do |agent| cert_path = on(agent, puppet('config', 'print', 'hostcert')).stdout.chomp key_path = on(agent, puppet('config', 'print', 'hostprivkey')).stdout.chomp cacert_path = on(agent, puppet('config', 'print', 'localcacert')).stdout.chomp agent_cert = on(agent, puppet('config', 'print', 'certname')).stdout.chomp run_count = 6 agent_tmpdir = agent.tmpdir("concurrent-loop-script") test_script = "#{agent_tmpdir}/loop.sh" create_remote_file(agent, test_script, <<-EOF) declare -a MYPIDS loops=#{run_count} for (( i=0; i<$loops; i++ )); do ( sleep_for="0.$(( $RANDOM % 49 ))" sleep $sleep_for - url='https://#{master}:8140/catalog/#{agent_cert}?environment=production' + url='https://#{master}:8140/v3/catalog/#{agent_cert}?environment=production' echo "Curling: $url" curl --tlsv1 -v -# -H 'Accept: text/pson' --cert #{cert_path} --key #{key_path} --cacert #{cacert_path} $url echo "$PPID Completed" ) > "#{agent_tmpdir}/catalog-request-$i.out" 2>&1 & echo "Launched $!" MYPIDS[$i]=$! done for (( i=0; i<$loops; i++ )); do wait ${MYPIDS[$i]} done echo "All requests are finished" EOF on(agent, "chmod +x #{test_script}") on(agent, "#{test_script}") run_count.times do |i| step "Checking the results of catalog request ##{i}" on(agent, "cat #{agent_tmpdir}/catalog-request-#{i}.out") do assert_match(%r{< HTTP/1.* 200}, stdout) assert_match(%r{touched the file system for a bit}, stdout) end end end end diff --git a/acceptance/tests/external_ca_support/fixtures/auth.conf b/acceptance/tests/external_ca_support/fixtures/auth.conf index f0d589588..198927049 100644 --- a/acceptance/tests/external_ca_support/fixtures/auth.conf +++ b/acceptance/tests/external_ca_support/fixtures/auth.conf @@ -1,60 +1,60 @@ # Puppet 3.1.1 auth.conf, modified to allow requests from example.org for # external ca testing. # allow nodes to retrieve their own catalog -path ~ ^/catalog/([^/]+)$ +path ~ ^/v3/catalog/([^/]+)$ method find allow *.example.org allow $1 # allow nodes to retrieve their own node definition -path ~ ^/node/([^/]+)$ +path ~ ^/v3/node/([^/]+)$ method find allow *.example.org allow $1 # allow all nodes to access the certificates services -path /certificate_revocation_list/ca +path /v3/certificate_revocation_list/ca method find allow * # allow all nodes to store their own reports -path ~ ^/report/([^/]+)$ +path ~ ^/v3/report/([^/]+)$ method save allow *.example.org allow $1 # Allow all nodes to access all file services; this is necessary for # pluginsync, file serving from modules, and file serving from custom # mount points (see fileserver.conf). Note that the `/file` prefix matches # requests to both the file_metadata and file_content paths. See "Examples" # above if you need more granular access control for custom mount points. -path /file +path /v3/file allow * ### Unauthenticated ACLs, for clients without valid certificates; authenticated ### clients can also access these paths, though they rarely need to. # allow access to the CA certificate; unauthenticated nodes need this # in order to validate the puppet master's certificate -path /certificate/ca +path /v3/certificate/ca auth any method find allow * # allow nodes to retrieve the certificate they requested earlier -path /certificate/ +path /v3/certificate/ auth any method find allow * # allow nodes to request a new certificate -path /certificate_request +path /v3/certificate_request auth any method find, save allow * # deny everything else; this ACL is not strictly necessary, but # illustrates the default policy. path / auth any diff --git a/acceptance/tests/node/check_woy_cache_works.rb b/acceptance/tests/node/check_woy_cache_works.rb index 881d1aa20..9fc2357e0 100644 --- a/acceptance/tests/node/check_woy_cache_works.rb +++ b/acceptance/tests/node/check_woy_cache_works.rb @@ -1,55 +1,55 @@ require 'securerandom' require 'puppet/acceptance/temp_file_utils' require 'yaml' extend Puppet::Acceptance::TempFileUtils test_name "ticket #16753 node data should be cached in yaml to allow it to be queried" node_name = "woy_node_#{SecureRandom.hex}" auth_contents = < { 'rest_authconfig' => authfile, 'yamldir' => temp_yamldir, } } with_puppet_running_on master, master_opts do # only one agent is needed because we only care about the file written on the master run_agent_on(agents[0], "--no-daemonize --verbose --onetime --node_name_value #{node_name} --server #{master}") yamldir = on(master, puppet('master', '--configprint', 'yamldir')).stdout.chomp on master, puppet('node', 'search', '"*"', '--node_terminus', 'yaml', '--clientyamldir', yamldir, '--render-as', 'json') do assert_match(/"name":["\s]*#{node_name}/, stdout, "Expect node name '#{node_name}' to be present in node yaml content written by the WriteOnlyYaml terminus") end end diff --git a/acceptance/tests/security/cve-2013-1652_improper_query_params.rb b/acceptance/tests/security/cve-2013-1652_improper_query_params.rb index 4c805c761..0f814a8e2 100644 --- a/acceptance/tests/security/cve-2013-1652_improper_query_params.rb +++ b/acceptance/tests/security/cve-2013-1652_improper_query_params.rb @@ -1,39 +1,39 @@ require 'json' test_name "CVE 2013-1652 Improper query parameter validation" do confine :except, :platform => 'windows' with_puppet_running_on master, {} do # Ensure each agent has a signed cert on agents, puppet('agent', "-t --server #{master}" ) agents.each do |agent| next if agent['roles'].include?( 'master' ) certname = on(agent, puppet('agent', "--configprint certname")).stdout.chomp - payload = "https://#{master}:8140/production/catalog/#{certname}?use_node=" + + payload = "https://#{master}:8140/v3/catalog/#{certname}?environment=production&use_node=" + "---%20!ruby/object:Puppet::Node%0A%20%20" + "name:%20#{master}%0A%20%20classes:%20\[\]%0A%20%20" + "parameters:%20%7B%7D%0A%20%20facts:%20%7B%7D" cert_path = on(agent, puppet('agent', "--configprint hostcert")).stdout.chomp key_path = on(agent, puppet('agent', "--configprint hostprivkey")).stdout.chomp curl_base = "curl --tlsv1 -g --cert \"#{cert_path}\" --key \"#{key_path}\" -k -H 'Accept: pson'" curl_call = "#{curl_base} '#{payload}'" step "Attempt to retrieve another nodes catalog" do on agent, curl_call do |test| begin res = JSON.parse( test.stdout ) fail_test( "Retrieved catalog for #{master} from #{agent}" ) if res['data']['name'] == master.name rescue JSON::ParserError # good, continue end end end end end end diff --git a/acceptance/tests/security/cve-2013-2275_report_acl.rb b/acceptance/tests/security/cve-2013-2275_report_acl.rb index 41a0a23fc..45d913a73 100644 --- a/acceptance/tests/security/cve-2013-2275_report_acl.rb +++ b/acceptance/tests/security/cve-2013-2275_report_acl.rb @@ -1,30 +1,30 @@ test_name "(#19531) report save access control" step "Verify puppet only allows saving reports from the node matching the certificate" fake_report = <<-EOYAML --- !ruby/object:Puppet::Transaction::Report host: mccune metrics: {} logs: [] kind: inspect puppet_version: "2.7.20" status: failed report_format: 3 EOYAML with_puppet_running_on(master, {}) do submit_fake_report_cmd = [ "curl --tlsv1 -k -X PUT", "--cacert \"$(puppet master --configprint cacert)\"", "--cert \"$(puppet master --configprint hostcert)\"", "--key \"$(puppet master --configprint hostprivkey)\"", "-H 'Content-Type: text/yaml'", "-d '#{fake_report}'", - "\"https://#{master}:8140/report/mccune?environment=production\"", + "\"https://#{master}:8140/v3/report/mccune?environment=production\"", ].join(" ") on master, submit_fake_report_cmd, :acceptable_exit_codes => [0] do msg = "(#19531) (CVE-2013-2275) Puppet master accepted a report for a node that does not match the certname" assert_match(/Forbidden request/, stdout, msg) end end diff --git a/conf/auth.conf b/conf/auth.conf index 96f078c48..dbadeaa10 100644 --- a/conf/auth.conf +++ b/conf/auth.conf @@ -1,120 +1,120 @@ # This is the default auth.conf file, which implements the default rules # used by the puppet master. (That is, the rules below will still apply # even if this file is deleted.) # # The ACLs are evaluated in top-down order. More specific stanzas should # be towards the top of the file and more general ones at the bottom; # otherwise, the general rules may "steal" requests that should be # governed by the specific rules. # # See http://docs.puppetlabs.com/guides/rest_auth_conf.html for a more complete # description of auth.conf's behavior. # # Supported syntax: # Each stanza in auth.conf starts with a path to match, followed # by optional modifiers, and finally, a series of allow or deny # directives. # # Example Stanza # --------------------------------- # path /path/to/resource # simple prefix match # # path ~ regex # alternately, regex match # [environment envlist] # [method methodlist] # [auth[enthicated] {yes|no|on|off|any}] # allow [host|backreference|*|regex] # deny [host|backreference|*|regex] # allow_ip [ip|cidr|ip_wildcard|*] # deny_ip [ip|cidr|ip_wildcard|*] # # The path match can either be a simple prefix match or a regular # expression. `path /file` would match both `/file_metadata` and # `/file_content`. Regex matches allow the use of backreferences # in the allow/deny directives. # # The regex syntax is the same as for Ruby regex, and captures backreferences # for use in the `allow` and `deny` lines of that stanza # # Examples: # # path ~ ^/path/to/resource # Equivalent to `path /path/to/resource`. # allow * # Allow all authenticated nodes (since auth # # defaults to `yes`). # # path ~ ^/catalog/([^/]+)$ # Permit nodes to access their own catalog (by # allow $1 # certname), but not any other node's catalog. # # path ~ ^/file_(metadata|content)/extra_files/ # Only allow certain nodes to # auth yes # access the "extra_files" # allow /^(.+)\.example\.com$/ # mount point; note this must # allow_ip 192.168.100.0/24 # go ABOVE the "/file" rule, # # since it is more specific. # # environment:: restrict an ACL to a comma-separated list of environments # method:: restrict an ACL to a comma-separated list of HTTP methods # auth:: restrict an ACL to an authenticated or unauthenticated request # the default when unspecified is to restrict the ACL to authenticated requests # (ie exactly as if auth yes was present). # ### Authenticated ACLs - these rules apply only when the client ### has a valid certificate and is thus authenticated # allow nodes to retrieve their own catalog -path ~ ^/catalog/([^/]+)$ +path ~ ^/v3/catalog/([^/]+)$ method find allow $1 # allow nodes to retrieve their own node definition -path ~ ^/node/([^/]+)$ +path ~ ^/v3/node/([^/]+)$ method find allow $1 # allow all nodes to access the certificates services -path /certificate_revocation_list/ca +path /v3/certificate_revocation_list/ca method find allow * # allow all nodes to store their own reports -path ~ ^/report/([^/]+)$ +path ~ ^/v3/report/([^/]+)$ method save allow $1 # Allow all nodes to access all file services; this is necessary for # pluginsync, file serving from modules, and file serving from custom # mount points (see fileserver.conf). Note that the `/file` prefix matches # requests to both the file_metadata and file_content paths. See "Examples" # above if you need more granular access control for custom mount points. -path /file +path /v3/file allow * ### Unauthenticated ACLs, for clients without valid certificates; authenticated ### clients can also access these paths, though they rarely need to. # allow access to the CA certificate; unauthenticated nodes need this # in order to validate the puppet master's certificate -path /certificate/ca +path /v3/certificate/ca auth any method find allow * # allow nodes to retrieve the certificate they requested earlier -path /certificate/ +path /v3/certificate/ auth any method find allow * # allow nodes to request a new certificate -path /certificate_request +path /v3/certificate_request auth any method find, save allow * path /v2.0/environments method find allow * # deny everything else; this ACL is not strictly necessary, but # illustrates the default policy. path / auth any diff --git a/lib/puppet/indirector/request.rb b/lib/puppet/indirector/request.rb index 7fb5f3801..0ef23821f 100644 --- a/lib/puppet/indirector/request.rb +++ b/lib/puppet/indirector/request.rb @@ -1,259 +1,255 @@ require 'cgi' require 'uri' require 'puppet/indirector' require 'puppet/network/resolver' require 'puppet/util/psych_support' # This class encapsulates all of the information you need to make an # Indirection call, and as a result also handles REST calls. It's somewhat # analogous to an HTTP Request object, except tuned for our Indirector. class Puppet::Indirector::Request include Puppet::Util::PsychSupport attr_accessor :key, :method, :options, :instance, :node, :ip, :authenticated, :ignore_cache, :ignore_terminus attr_accessor :server, :port, :uri, :protocol attr_reader :indirection_name # trusted_information is specifically left out because we can't serialize it # and keep it "trusted" OPTION_ATTRIBUTES = [:ip, :node, :authenticated, :ignore_terminus, :ignore_cache, :instance, :environment] # Is this an authenticated request? def authenticated? # Double negative, so we just get true or false ! ! authenticated end def environment # If environment has not been set directly, we should use the application's # current environment @environment ||= Puppet.lookup(:current_environment) end def environment=(env) @environment = if env.is_a?(Puppet::Node::Environment) env elsif (current_environment = Puppet.lookup(:current_environment)).name == env current_environment else Puppet.lookup(:environments).get!(env) end end def escaped_key URI.escape(key) end # LAK:NOTE This is a messy interface to the cache, and it's only # used by the Configurer class. I decided it was better to implement # it now and refactor later, when we have a better design, than # to spend another month coming up with a design now that might # not be any better. def ignore_cache? ignore_cache end def ignore_terminus? ignore_terminus end def initialize(indirection_name, method, key, instance, options = {}) @instance = instance options ||= {} self.indirection_name = indirection_name self.method = method options = options.inject({}) { |hash, ary| hash[ary[0].to_sym] = ary[1]; hash } set_attributes(options) @options = options if key # If the request key is a URI, then we need to treat it specially, # because it rewrites the key. We could otherwise strip server/port/etc # info out in the REST class, but it seemed bad design for the REST # class to rewrite the key. if key.to_s =~ /^\w+:\// and not Puppet::Util.absolute_path?(key.to_s) # it's a URI set_uri_key(key) else @key = key end end @key = @instance.name if ! @key and @instance end # Look up the indirection based on the name provided. def indirection Puppet::Indirector::Indirection.instance(indirection_name) end def indirection_name=(name) @indirection_name = name.to_sym end def model raise ArgumentError, "Could not find indirection '#{indirection_name}'" unless i = indirection i.model end # Are we trying to interact with multiple resources, or just one? def plural? method == :search end # Create the query string, if options are present. def query_string return "" if options.nil? || options.empty? encode_params(expand_into_parameters(options.to_a)) end def expand_into_parameters(data) data.inject([]) do |params, key_value| key, value = key_value expanded_value = case value when Array value.collect { |val| [key, val] } else [key_value] end params.concat(expand_primitive_types_into_parameters(expanded_value)) end end def expand_primitive_types_into_parameters(data) data.inject([]) do |params, key_value| key, value = key_value case value when nil params when true, false, String, Symbol, Fixnum, Bignum, Float params << [key, value] else raise ArgumentError, "HTTP REST queries cannot handle values of type '#{value.class}'" end end end def encode_params(params) params.collect do |key, value| "#{key}=#{CGI.escape(value.to_s)}" end.join("&") end def initialize_from_hash(hash) @indirection_name = hash['indirection_name'].to_sym @method = hash['method'].to_sym @key = hash['key'] @instance = hash['instance'] @options = hash['options'] end def to_data_hash { 'indirection_name' => @indirection_name.to_s, 'method' => @method.to_s, 'key' => @key, 'instance' => @instance, 'options' => @options } end def to_hash result = options.dup OPTION_ATTRIBUTES.each do |attribute| if value = send(attribute) result[attribute] = value end end result end - def to_s - return(uri ? uri : "/#{indirection_name}/#{key}") - end - def do_request(srv_service=:puppet, default_server=Puppet.settings[:server], default_port=Puppet.settings[:masterport], &block) # We were given a specific server to use, so just use that one. # This happens if someone does something like specifying a file # source using a puppet:// URI with a specific server. return yield(self) if !self.server.nil? if Puppet.settings[:use_srv_records] Puppet::Network::Resolver.each_srv_record(Puppet.settings[:srv_domain], srv_service) do |srv_server, srv_port| begin self.server = srv_server self.port = srv_port return yield(self) rescue SystemCallError => e Puppet.warning "Error connecting to #{srv_server}:#{srv_port}: #{e.message}" end end end # ... Fall back onto the default server. Puppet.debug "No more servers left, falling back to #{default_server}:#{default_port}" if Puppet.settings[:use_srv_records] self.server = default_server self.port = default_port return yield(self) end def remote? self.node or self.ip end private def set_attributes(options) OPTION_ATTRIBUTES.each do |attribute| if options.include?(attribute.to_sym) send(attribute.to_s + "=", options[attribute]) options.delete(attribute) end end end # Parse the key as a URI, setting attributes appropriately. def set_uri_key(key) @uri = key begin uri = URI.parse(URI.escape(key)) rescue => detail raise ArgumentError, "Could not understand URL #{key}: #{detail}", detail.backtrace end # Just short-circuit these to full paths if uri.scheme == "file" @key = Puppet::Util.uri_to_path(uri) return end @server = uri.host if uri.host # If the URI class can look up the scheme, it will provide a port, # otherwise it will default to '0'. if uri.port.to_i == 0 and uri.scheme == "puppet" @port = Puppet.settings[:masterport].to_i else @port = uri.port.to_i end @protocol = uri.scheme if uri.scheme == 'puppet' @key = URI.unescape(uri.path.sub(/^\//, '')) return end env, indirector, @key = URI.unescape(uri.path.sub(/^\//, '')).split('/',3) @key ||= '' self.environment = env unless env == '' end end diff --git a/lib/puppet/indirector/rest.rb b/lib/puppet/indirector/rest.rb index 979577957..982184b65 100644 --- a/lib/puppet/indirector/rest.rb +++ b/lib/puppet/indirector/rest.rb @@ -1,248 +1,249 @@ require 'net/http' require 'uri' require 'puppet/network/http' +require 'puppet/network/http/api/v3' require 'puppet/network/http_pool' # Access objects via REST class Puppet::Indirector::REST < Puppet::Indirector::Terminus include Puppet::Network::HTTP::Compression.module class << self attr_reader :server_setting, :port_setting end # Specify the setting that we should use to get the server name. def self.use_server_setting(setting) @server_setting = setting end # Specify the setting that we should use to get the port. def self.use_port_setting(setting) @port_setting = setting end # Specify the service to use when doing SRV record lookup def self.use_srv_service(service) @srv_service = service end def self.srv_service @srv_service || :puppet end def self.server Puppet.settings[server_setting || :server] end def self.port Puppet.settings[port_setting || :masterport].to_i end # Provide appropriate headers. def headers add_accept_encoding({"Accept" => model.supported_formats.join(", ")}) end def add_profiling_header(headers) if (Puppet[:profile]) headers[Puppet::Network::HTTP::HEADER_ENABLE_PROFILING] = "true" end headers end def network(request) Puppet::Network::HttpPool.http_instance(request.server || self.class.server, request.port || self.class.port) end def http_get(request, path, headers = nil, *args) http_request(:get, request, path, add_profiling_header(headers), *args) end def http_post(request, path, data, headers = nil, *args) http_request(:post, request, path, data, add_profiling_header(headers), *args) end def http_head(request, path, headers = nil, *args) http_request(:head, request, path, add_profiling_header(headers), *args) end def http_delete(request, path, headers = nil, *args) http_request(:delete, request, path, add_profiling_header(headers), *args) end def http_put(request, path, data, headers = nil, *args) http_request(:put, request, path, data, add_profiling_header(headers), *args) end def http_request(method, request, *args) conn = network(request) conn.send(method, *args) end def find(request) - uri, body = Puppet::Network::HTTP::API::V1.request_to_uri_and_body(request) + uri, body = Puppet::Network::HTTP::API::V3.request_to_uri_and_body(request) uri_with_query_string = "#{uri}?#{body}" response = do_request(request) do |req| # WEBrick in Ruby 1.9.1 only supports up to 1024 character lines in an HTTP request # http://redmine.ruby-lang.org/issues/show/3991 if "GET #{uri_with_query_string} HTTP/1.1\r\n".length > 1024 http_post(req, uri, body, headers) else http_get(req, uri_with_query_string, headers) end end if is_http_200?(response) content_type, body = parse_response(response) result = deserialize_find(content_type, body) result.name = request.key if result.respond_to?(:name=) result elsif is_http_404?(response) return nil unless request.options[:fail_on_404] # 404 can get special treatment as the indirector API can not produce a meaningful # reason to why something is not found - it may not be the thing the user is # expecting to find that is missing, but something else (like the environment). # While this way of handling the issue is not perfect, there is at least an error # that makes a user aware of the reason for the failure. # content_type, body = parse_response(response) msg = "Find #{elide(uri_with_query_string, 100)} resulted in 404 with the message: #{body}" raise Puppet::Error, msg else nil end end def head(request) response = do_request(request) do |req| - http_head(req, Puppet::Network::HTTP::API::V1.request_to_uri(req), headers) + http_head(req, Puppet::Network::HTTP::API::V3.request_to_uri(req), headers) end if is_http_200?(response) true else false end end def search(request) response = do_request(request) do |req| - http_get(req, Puppet::Network::HTTP::API::V1.request_to_uri(req), headers) + http_get(req, Puppet::Network::HTTP::API::V3.request_to_uri(req), headers) end if is_http_200?(response) content_type, body = parse_response(response) deserialize_search(content_type, body) || [] else [] end end def destroy(request) raise ArgumentError, "DELETE does not accept options" unless request.options.empty? response = do_request(request) do |req| - http_delete(req, Puppet::Network::HTTP::API::V1.request_to_uri(req), headers) + http_delete(req, Puppet::Network::HTTP::API::V3.request_to_uri(req), headers) end if is_http_200?(response) content_type, body = parse_response(response) deserialize_destroy(content_type, body) else nil end end def save(request) raise ArgumentError, "PUT does not accept options" unless request.options.empty? response = do_request(request) do |req| - http_put(req, Puppet::Network::HTTP::API::V1.request_to_uri(req), req.instance.render, headers.merge({ "Content-Type" => req.instance.mime })) + http_put(req, Puppet::Network::HTTP::API::V3.request_to_uri(req), req.instance.render, headers.merge({ "Content-Type" => req.instance.mime })) end if is_http_200?(response) content_type, body = parse_response(response) deserialize_save(content_type, body) else nil end end # Encapsulate call to request.do_request with the arguments from this class # Then yield to the code block that was called in # We certainly could have retained the full request.do_request(...) { |r| ... } # but this makes the code much cleaner and we only then actually make the call # to request.do_request from here, thus if we change what we pass or how we # get it, we only need to change it here. def do_request(request) request.do_request(self.class.srv_service, self.class.server, self.class.port) { |req| yield(req) } end def validate_key(request) # Validation happens on the remote end end private def is_http_200?(response) case response.code when "404" false when /^2/ true else # Raise the http error if we didn't get a 'success' of some kind. raise convert_to_http_error(response) end end def is_http_404?(response) response.code == "404" end def convert_to_http_error(response) message = "Error #{response.code} on SERVER: #{(response.body||'').empty? ? response.message : uncompress_body(response)}" Net::HTTPError.new(message, response) end # Returns the content_type, stripping any appended charset, and the # body, decompressed if necessary (content-encoding is checked inside # uncompress_body) def parse_response(response) if response['content-type'] [ response['content-type'].gsub(/\s*;.*$/,''), body = uncompress_body(response) ] else raise "No content type in http response; cannot parse" end end def deserialize_find(content_type, body) model.convert_from(content_type, body) end def deserialize_search(content_type, body) model.convert_from_multiple(content_type, body) end def deserialize_destroy(content_type, body) model.convert_from(content_type, body) end def deserialize_save(content_type, body) nil end def elide(string, length) if Puppet::Util::Log.level == :debug || string.length <= length string else string[0, length - 3] + "..." end end end diff --git a/lib/puppet/network/authconfig.rb b/lib/puppet/network/authconfig.rb index d4302d44c..05ad1105c 100644 --- a/lib/puppet/network/authconfig.rb +++ b/lib/puppet/network/authconfig.rb @@ -1,76 +1,77 @@ require 'puppet/network/rights' module Puppet class ConfigurationError < Puppet::Error; end class Network::AuthConfig attr_accessor :rights DEFAULT_ACL = [ - { :acl => "~ ^\/catalog\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, - { :acl => "~ ^\/node\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, + # API V2.0 + { :acl => "/v2.0/environments", :method => :find, :allow => '*', :authenticated => true }, + + # API V3 + { :acl => "~ ^\/v3\/catalog\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, + { :acl => "~ ^\/v3\/node\/([^\/]+)$", :method => :find, :allow => '$1', :authenticated => true }, # this one will allow all file access, and thus delegate # to fileserver.conf - { :acl => "/file" }, - { :acl => "/certificate_revocation_list/ca", :method => :find, :authenticated => true }, - { :acl => "~ ^\/report\/([^\/]+)$", :method => :save, :allow => '$1', :authenticated => true }, + { :acl => "/v3/file" }, + { :acl => "/v3/certificate_revocation_list/ca", :method => :find, :authenticated => true }, + { :acl => "~ ^\/v3\/report\/([^\/]+)$", :method => :save, :allow => '$1', :authenticated => true }, # These allow `auth any`, because if you can do them anonymously you # should probably also be able to do them when trusted. - { :acl => "/certificate/ca", :method => :find, :authenticated => :any }, - { :acl => "/certificate/", :method => :find, :authenticated => :any }, - { :acl => "/certificate_request", :method => [:find, :save], :authenticated => :any }, - { :acl => "/status", :method => [:find], :authenticated => true }, - - # API V2.0 - { :acl => "/v2.0/environments", :method => :find, :allow => '*', :authenticated => true }, + { :acl => "/v3/certificate/ca", :method => :find, :authenticated => :any }, + { :acl => "/v3/certificate/", :method => :find, :authenticated => :any }, + { :acl => "/v3/certificate_request", :method => [:find, :save], :authenticated => :any }, + { :acl => "/v3/status", :method => [:find], :authenticated => true }, ] # Just proxy the setting methods to our rights stuff [:allow, :deny].each do |method| define_method(method) do |*args| @rights.send(method, *args) end end # force regular ACLs to be present def insert_default_acl DEFAULT_ACL.each do |acl| unless rights[acl[:acl]] Puppet.info "Inserting default '#{acl[:acl]}' (auth #{acl[:authenticated]}) ACL" mk_acl(acl) end end # queue an empty (ie deny all) right for every other path # actually this is not strictly necessary as the rights system # denies not explicitly allowed paths unless rights["/"] rights.newright("/").restrict_authenticated(:any) end end def mk_acl(acl) right = @rights.newright(acl[:acl]) right.allow(acl[:allow] || "*") if method = acl[:method] method = [method] unless method.is_a?(Array) method.each { |m| right.restrict_method(m) } end right.restrict_authenticated(acl[:authenticated]) unless acl[:authenticated].nil? end # check whether this request is allowed in our ACL # raise an Puppet::Network::AuthorizedError if the request # is denied. def check_authorization(method, path, params) if authorization_failure_exception = @rights.is_request_forbidden_and_why?(method, path, params) Puppet.warning("Denying access: #{authorization_failure_exception}") raise authorization_failure_exception end end def initialize(rights=nil) @rights = rights || Puppet::Network::Rights.new insert_default_acl end end end diff --git a/lib/puppet/network/http.rb b/lib/puppet/network/http.rb index e302d6aa6..e3af2d6f1 100644 --- a/lib/puppet/network/http.rb +++ b/lib/puppet/network/http.rb @@ -1,21 +1,22 @@ module Puppet::Network::HTTP HEADER_ENABLE_PROFILING = "X-Puppet-Profiling" HEADER_PUPPET_VERSION = "X-Puppet-Version" + require 'puppet/network/authorization' require 'puppet/network/http/issues' require 'puppet/network/http/error' require 'puppet/network/http/route' require 'puppet/network/http/api' - require 'puppet/network/http/api/v1' require 'puppet/network/http/api/v2' + require 'puppet/network/http/api/v3' require 'puppet/network/http/handler' require 'puppet/network/http/response' require 'puppet/network/http/request' require 'puppet/network/http/site' require 'puppet/network/http/session' require 'puppet/network/http/factory' require 'puppet/network/http/nocache_pool' require 'puppet/network/http/pool' require 'puppet/network/http/memory_response' require 'puppet/network/http/compression' end diff --git a/lib/puppet/network/http/api/v1.rb b/lib/puppet/network/http/api/v1.rb deleted file mode 100644 index b642e39d9..000000000 --- a/lib/puppet/network/http/api/v1.rb +++ /dev/null @@ -1,232 +0,0 @@ -require 'puppet/network/authorization' - -class Puppet::Network::HTTP::API::V1 - include Puppet::Network::Authorization - - # How we map http methods and the indirection name in the URI - # to an indirection method. - METHOD_MAP = { - "GET" => { - :plural => :search, - :singular => :find - }, - "POST" => { - :singular => :find, - }, - "PUT" => { - :singular => :save - }, - "DELETE" => { - :singular => :destroy - }, - "HEAD" => { - :singular => :head - } - } - - def self.routes - Puppet::Network::HTTP::Route.path(/.*/).any(new) - end - - # handle an HTTP request - def call(request, response) - indirection_name, method, key, params = uri2indirection(request.method, request.path, request.params) - certificate = request.client_cert - - check_authorization(method, "/#{indirection_name}/#{key}", params) - - indirection = Puppet::Indirector::Indirection.instance(indirection_name.to_sym) - if !indirection - raise ArgumentError, "Could not find indirection '#{indirection_name}'" - end - - if !indirection.allow_remote_requests? - # TODO: should we tell the user we found an indirection but it doesn't - # allow remote requests, or just pretend there's no handler at all? what - # are the security implications for the former? - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("No handler for #{indirection.name}", :NO_INDIRECTION_REMOTE_REQUESTS) - end - - trusted = Puppet::Context::TrustedInformation.remote(params[:authenticated], params[:node], certificate) - Puppet.override(:trusted_information => trusted) do - send("do_#{method}", indirection, key, params, request, response) - end - rescue Puppet::Network::HTTP::Error::HTTPError => e - return do_http_control_exception(response, e) - rescue StandardError => e - return do_exception(response, e) - end - - def uri2indirection(http_method, uri, params) - indirection, key = uri.split("/", 3)[1..-1] # the first field is always nil because of the leading slash - environment = params.delete(:environment) - - if ! Puppet::Node::Environment.valid_name?(environment) - raise ArgumentError, "The environment must be purely alphanumeric, not '#{environment}'" - end - - if indirection !~ /^\w+$/ - raise ArgumentError, "The indirection name must be purely alphanumeric, not '#{indirection}'" - end - - method = indirection_method(http_method, indirection) - - configured_environment = Puppet.lookup(:environments).get(environment) - if configured_environment.nil? - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new( - "Could not find environment '#{environment}'", - Puppet::Network::HTTP::Issues::ENVIRONMENT_NOT_FOUND) - else - configured_environment = configured_environment.override_from_commandline(Puppet.settings) - params[:environment] = configured_environment - end - - params.delete(:bucket_path) - - if key == "" or key.nil? - raise ArgumentError, "No request key specified in #{uri}" - end - - key = URI.unescape(key) - - [indirection, method, key, params] - end - - private - - def do_http_control_exception(response, exception) - msg = exception.message - Puppet.info(msg) - response.respond_with(exception.status, "text/plain", msg) - 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 - - Puppet.log_exception(exception) - - response.respond_with(status, "text/plain", exception.to_s) - end - - # Execute our find. - def do_find(indirection, key, params, request, response) - unless result = indirection.find(key, params) - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) - end - - format = accepted_response_formatter_for(indirection.model, request) - - rendered_result = result - if result.respond_to?(:render) - Puppet::Util::Profiler.profile("Rendered result in #{format}", [:http, :v1_render, format]) do - rendered_result = result.render(format) - end - end - - Puppet::Util::Profiler.profile("Sent response", [:http, :v1_response]) do - response.respond_with(200, format, rendered_result) - end - end - - # Execute our head. - def do_head(indirection, key, params, request, response) - unless indirection.head(key, params) - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) - end - - # No need to set a response because no response is expected from a - # HEAD request. All we need to do is not die. - end - - # Execute our search. - def do_search(indirection, key, params, request, response) - result = indirection.search(key, params) - - if result.nil? - raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find instances in #{indirection.name} with '#{key}'", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) - end - - format = accepted_response_formatter_for(indirection.model, request) - - response.respond_with(200, format, indirection.model.render_multiple(format, result)) - end - - # Execute our destroy. - def do_destroy(indirection, key, params, request, response) - formatter = accepted_response_formatter_or_pson_for(indirection.model, request) - - result = indirection.destroy(key, params) - - response.respond_with(200, formatter, formatter.render(result)) - end - - # Execute our save. - def do_save(indirection, key, params, request, response) - formatter = accepted_response_formatter_or_pson_for(indirection.model, request) - sent_object = read_body_into_model(indirection.model, request) - - result = indirection.save(sent_object, key) - - response.respond_with(200, formatter, formatter.render(result)) - end - - def accepted_response_formatter_for(model_class, request) - accepted_formats = request.headers['accept'] or raise Puppet::Network::HTTP::Error::HTTPNotAcceptableError.new("Missing required Accept header", Puppet::Network::HTTP::Issues::MISSING_HEADER_FIELD) - request.response_formatter_for(model_class.supported_formats, accepted_formats) - end - - def accepted_response_formatter_or_pson_for(model_class, request) - accepted_formats = request.headers['accept'] || "text/pson" - request.response_formatter_for(model_class.supported_formats, accepted_formats) - end - - def read_body_into_model(model_class, request) - data = request.body.to_s - - format = request.format - model_class.convert_from(format, data) - end - - def indirection_method(http_method, indirection) - raise ArgumentError, "No support for http method #{http_method}" unless METHOD_MAP[http_method] - - unless method = METHOD_MAP[http_method][plurality(indirection)] - raise ArgumentError, "No support for plurality #{plurality(indirection)} for #{http_method} operations" - end - - method - end - - def self.request_to_uri(request) - uri, body = request_to_uri_and_body(request) - "#{uri}?#{body}" - end - - def self.request_to_uri_and_body(request) - indirection = request.method == :search ? pluralize(request.indirection_name.to_s) : request.indirection_name.to_s - ["/#{indirection}/#{request.escaped_key}", "environment=#{request.environment.name}&#{request.query_string}"] - end - - def self.pluralize(indirection) - return(indirection == "status" ? "statuses" : indirection + "s") - end - - def plurality(indirection) - # NOTE These specific hooks for paths are ridiculous, but it's a *many*-line - # fix to not need this, and our goal is to move away from the complication - # that leads to the fix being too long. - return :singular if indirection == "status" - return :singular if indirection == "certificate_status" - - result = (indirection =~ /s$|_search$/) ? :plural : :singular - - indirection.sub!(/s$|_search$/, '') - indirection.sub!(/statuse$/, 'status') - - result - end -end diff --git a/lib/puppet/network/http/api/v3.rb b/lib/puppet/network/http/api/v3.rb index bf84f1334..7922b93e6 100644 --- a/lib/puppet/network/http/api/v3.rb +++ b/lib/puppet/network/http/api/v3.rb @@ -1,232 +1,234 @@ require 'puppet/network/authorization' class Puppet::Network::HTTP::API::V3 include Puppet::Network::Authorization # How we map http methods and the indirection name in the URI # to an indirection method. METHOD_MAP = { "GET" => { :plural => :search, :singular => :find }, "POST" => { :singular => :find, }, "PUT" => { :singular => :save }, "DELETE" => { :singular => :destroy }, "HEAD" => { :singular => :head } } def self.routes - Puppet::Network::HTTP::Route.path(/.*/).any(new) + Puppet::Network::HTTP::Route.path(%r{^/v3}).any(new) end # handle an HTTP request def call(request, response) indirection_name, method, key, params = uri2indirection(request.method, request.path, request.params) certificate = request.client_cert - check_authorization(method, "/#{indirection_name}/#{key}", params) + check_authorization(method, "/v3/#{indirection_name}/#{key}", params) indirection = Puppet::Indirector::Indirection.instance(indirection_name.to_sym) if !indirection raise ArgumentError, "Could not find indirection '#{indirection_name}'" end if !indirection.allow_remote_requests? # TODO: should we tell the user we found an indirection but it doesn't # allow remote requests, or just pretend there's no handler at all? what # are the security implications for the former? raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("No handler for #{indirection.name}", :NO_INDIRECTION_REMOTE_REQUESTS) end trusted = Puppet::Context::TrustedInformation.remote(params[:authenticated], params[:node], certificate) Puppet.override(:trusted_information => trusted) do send("do_#{method}", indirection, key, params, request, response) end rescue Puppet::Network::HTTP::Error::HTTPError => e return do_http_control_exception(response, e) rescue StandardError => e return do_exception(response, e) end def uri2indirection(http_method, uri, params) - indirection, key = uri.split("/", 3)[1..-1] # the first field is always nil because of the leading slash + # the first field is always nil because of the leading slash, + # and we also want to strip off the leading /v3. + indirection, key = uri.split("/", 4)[2..-1] environment = params.delete(:environment) if ! Puppet::Node::Environment.valid_name?(environment) raise ArgumentError, "The environment must be purely alphanumeric, not '#{environment}'" end if indirection !~ /^\w+$/ raise ArgumentError, "The indirection name must be purely alphanumeric, not '#{indirection}'" end method = indirection_method(http_method, indirection) configured_environment = Puppet.lookup(:environments).get(environment) if configured_environment.nil? raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new( "Could not find environment '#{environment}'", Puppet::Network::HTTP::Issues::ENVIRONMENT_NOT_FOUND) else configured_environment = configured_environment.override_from_commandline(Puppet.settings) params[:environment] = configured_environment end params.delete(:bucket_path) if key == "" or key.nil? raise ArgumentError, "No request key specified in #{uri}" end key = URI.unescape(key) [indirection, method, key, params] end private def do_http_control_exception(response, exception) msg = exception.message Puppet.info(msg) response.respond_with(exception.status, "text/plain", msg) 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 Puppet.log_exception(exception) response.respond_with(status, "text/plain", exception.to_s) end # Execute our find. def do_find(indirection, key, params, request, response) unless result = indirection.find(key, params) raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) end format = accepted_response_formatter_for(indirection.model, request) rendered_result = result if result.respond_to?(:render) Puppet::Util::Profiler.profile("Rendered result in #{format}", [:http, :v3_render, format]) do rendered_result = result.render(format) end end Puppet::Util::Profiler.profile("Sent response", [:http, :v3_response]) do response.respond_with(200, format, rendered_result) end end # Execute our head. def do_head(indirection, key, params, request, response) unless indirection.head(key, params) raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find #{indirection.name} #{key}", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) end # No need to set a response because no response is expected from a # HEAD request. All we need to do is not die. end # Execute our search. def do_search(indirection, key, params, request, response) result = indirection.search(key, params) if result.nil? raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("Could not find instances in #{indirection.name} with '#{key}'", Puppet::Network::HTTP::Issues::RESOURCE_NOT_FOUND) end format = accepted_response_formatter_for(indirection.model, request) response.respond_with(200, format, indirection.model.render_multiple(format, result)) end # Execute our destroy. def do_destroy(indirection, key, params, request, response) formatter = accepted_response_formatter_or_pson_for(indirection.model, request) result = indirection.destroy(key, params) response.respond_with(200, formatter, formatter.render(result)) end # Execute our save. def do_save(indirection, key, params, request, response) formatter = accepted_response_formatter_or_pson_for(indirection.model, request) sent_object = read_body_into_model(indirection.model, request) result = indirection.save(sent_object, key) response.respond_with(200, formatter, formatter.render(result)) end def accepted_response_formatter_for(model_class, request) accepted_formats = request.headers['accept'] or raise Puppet::Network::HTTP::Error::HTTPNotAcceptableError.new("Missing required Accept header", Puppet::Network::HTTP::Issues::MISSING_HEADER_FIELD) request.response_formatter_for(model_class.supported_formats, accepted_formats) end def accepted_response_formatter_or_pson_for(model_class, request) accepted_formats = request.headers['accept'] || "text/pson" request.response_formatter_for(model_class.supported_formats, accepted_formats) end def read_body_into_model(model_class, request) data = request.body.to_s format = request.format model_class.convert_from(format, data) end def indirection_method(http_method, indirection) raise ArgumentError, "No support for http method #{http_method}" unless METHOD_MAP[http_method] unless method = METHOD_MAP[http_method][plurality(indirection)] raise ArgumentError, "No support for plurality #{plurality(indirection)} for #{http_method} operations" end method end def self.request_to_uri(request) uri, body = request_to_uri_and_body(request) "#{uri}?#{body}" end def self.request_to_uri_and_body(request) indirection = request.method == :search ? pluralize(request.indirection_name.to_s) : request.indirection_name.to_s - ["/#{indirection}/#{request.escaped_key}", "environment=#{request.environment.name}&#{request.query_string}"] + ["/v3/#{indirection}/#{request.escaped_key}", "environment=#{request.environment.name}&#{request.query_string}"] end def self.pluralize(indirection) return(indirection == "status" ? "statuses" : indirection + "s") end def plurality(indirection) # NOTE These specific hooks for paths are ridiculous, but it's a *many*-line # fix to not need this, and our goal is to move away from the complication # that leads to the fix being too long. return :singular if indirection == "status" return :singular if indirection == "certificate_status" result = (indirection =~ /s$|_search$/) ? :plural : :singular indirection.sub!(/s$|_search$/, '') indirection.sub!(/statuse$/, 'status') result end end diff --git a/lib/puppet/network/http/handler.rb b/lib/puppet/network/http/handler.rb index 7851c3aae..7580fceef 100644 --- a/lib/puppet/network/http/handler.rb +++ b/lib/puppet/network/http/handler.rb @@ -1,179 +1,178 @@ module Puppet::Network::HTTP end require 'puppet/network/http' -require 'puppet/network/http/api/v1' require 'puppet/network/rights' require 'puppet/util/profiler' require 'puppet/util/profiler/aggregate' require 'resolv' module Puppet::Network::HTTP::Handler include Puppet::Network::HTTP::Issues # These shouldn't be allowed to be set by clients # in the query string, for security reasons. DISALLOWED_KEYS = ["node", "ip"] def register(routes) # There's got to be a simpler way to do this, right? dupes = {} routes.each { |r| dupes[r.path_matcher] = (dupes[r.path_matcher] || 0) + 1 } dupes = dupes.collect { |pm, count| pm if count > 1 }.compact if dupes.count > 0 raise ArgumentError, "Given multiple routes with identical path regexes: #{dupes.map{ |rgx| rgx.inspect }.join(', ')}" end @routes = routes Puppet.debug("Routes Registered:") @routes.each do |route| Puppet.debug(route.inspect) end end # Retrieve all headers from the http request, as a hash with the header names # (lower-cased) as the keys def headers(request) raise NotImplementedError end def format_to_mime(format) format.is_a?(Puppet::Network::Format) ? format.mime : format end # handle an HTTP request def process(request, response) new_response = Puppet::Network::HTTP::Response.new(self, response) request_headers = headers(request) request_params = params(request) request_method = http_method(request) request_path = path(request) new_request = Puppet::Network::HTTP::Request.new(request_headers, request_params, request_method, request_path, request_path, client_cert(request), body(request)) response[Puppet::Network::HTTP::HEADER_PUPPET_VERSION] = Puppet.version profiler = configure_profiler(request_headers, request_params) Puppet::Util::Profiler.profile("Processed request #{request_method} #{request_path}", [:http, request_method, request_path]) do if route = @routes.find { |r| r.matches?(new_request) } route.process(new_request, new_response) else raise Puppet::Network::HTTP::Error::HTTPNotFoundError.new("No route for #{new_request.method} #{new_request.path}", HANDLER_NOT_FOUND) end end rescue Puppet::Network::HTTP::Error::HTTPError => e Puppet.info(e.message) new_response.respond_with(e.status, "application/json", e.to_json) rescue StandardError => e http_e = Puppet::Network::HTTP::Error::HTTPServerError.new(e) Puppet.err(http_e.message) new_response.respond_with(http_e.status, "application/json", http_e.to_json) ensure if profiler remove_profiler(profiler) end cleanup(request) 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 # 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 # 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 client_cert(request) raise NotImplementedError end def cleanup(request) # By default, there is nothing to cleanup. end def decode_params(params) params.select { |key, _| allowed_parameter?(key) }.inject({}) do |result, ary| param, value = ary result[param.to_sym] = parse_parameter_value(param, value) result end end def allowed_parameter?(name) not (name.nil? || name.empty? || DISALLOWED_KEYS.include?(name)) end def parse_parameter_value(param, value) if value.is_a?(Array) value.collect { |v| parse_primitive_parameter_value(v) } else parse_primitive_parameter_value(value) end end def parse_primitive_parameter_value(value) case value when "true" true when "false" false when /^\d+$/ Integer(value) when /^\d+\.\d+$/ value.to_f else value end end def configure_profiler(request_headers, request_params) if (request_headers.has_key?(Puppet::Network::HTTP::HEADER_ENABLE_PROFILING.downcase) or Puppet[:profile]) Puppet::Util::Profiler.add_profiler(Puppet::Util::Profiler::Aggregate.new(Puppet.method(:debug), request_params.object_id)) end end def remove_profiler(profiler) profiler.shutdown Puppet::Util::Profiler.remove_profiler(profiler) end end diff --git a/lib/puppet/network/http/rack/rest.rb b/lib/puppet/network/http/rack/rest.rb index 23d73bf93..1a2e1563f 100644 --- a/lib/puppet/network/http/rack/rest.rb +++ b/lib/puppet/network/http/rack/rest.rb @@ -1,136 +1,136 @@ require 'openssl' require 'cgi' require 'puppet/network/http/handler' require 'puppet/util/ssl' class Puppet::Network::HTTP::RackREST include Puppet::Network::HTTP::Handler ContentType = 'Content-Type'.freeze CHUNK_SIZE = 8192 class RackFile def initialize(file) @file = file end def each while chunk = @file.read(CHUNK_SIZE) yield chunk end end def close @file.close end end def initialize(args={}) super() - register([Puppet::Network::HTTP::API::V2.routes, Puppet::Network::HTTP::API::V1.routes]) + register([Puppet::Network::HTTP::API::V3.routes, Puppet::Network::HTTP::API::V2.routes]) end def set_content_type(response, format) response[ContentType] = format_to_mime(format) end # produce the body of the response def set_response(response, result, status = 200) response.status = status unless result.is_a?(File) response.write result else response["Content-Length"] = result.stat.size.to_s response.body = RackFile.new(result) end end # Retrieve all headers from the http request, as a map. def headers(request) headers = request.env.select {|k,v| k.start_with? 'HTTP_'}.inject({}) do |m, (k,v)| m[k.sub(/^HTTP_/, '').gsub('_','-').downcase] = v m end headers['content-type'] = request.content_type headers end # Return which HTTP verb was used in this request. def http_method(request) request.request_method end # Return the query params for this request. def params(request) if request.post? params = request.params else # rack doesn't support multi-valued query parameters, # e.g. ignore, so parse them ourselves params = CGI.parse(request.query_string) convert_singular_arrays_to_value(params) end result = decode_params(params) result.merge(extract_client_info(request)) end # what path was requested? (this is, without any query parameters) def path(request) request.path end # return the request body def body(request) request.body.read end def client_cert(request) # This environment variable is set by mod_ssl, note that it # requires the `+ExportCertData` option in the `SSLOptions` directive cert = request.env['SSL_CLIENT_CERT'] # NOTE: The SSL_CLIENT_CERT environment variable will be the empty string # when Puppet agent nodes have not yet obtained a signed certificate. if cert.nil? || cert.empty? nil else Puppet::SSL::Certificate.from_instance(OpenSSL::X509::Certificate.new(cert)) end end # Passenger freaks out if we finish handling the request without reading any # part of the body, so make sure we have. def cleanup(request) request.body.read(1) nil end def extract_client_info(request) result = {} result[:ip] = request.ip # if we find SSL info in the headers, use them to get a hostname from the CN. # try this with :ssl_client_header, which defaults should work for # Apache with StdEnvVars. subj_str = request.env[Puppet[:ssl_client_header]] subject = Puppet::Util::SSL.subject_from_dn(subj_str || "") if cn = Puppet::Util::SSL.cn_from_subject(subject) result[:node] = cn result[:authenticated] = (request.env[Puppet[:ssl_client_verify_header]] == 'SUCCESS') else result[:node] = resolve_node(result) result[:authenticated] = false end result end def convert_singular_arrays_to_value(hash) hash.each do |key, value| if value.size == 1 hash[key] = value.first end end end end diff --git a/lib/puppet/network/http/webrick/rest.rb b/lib/puppet/network/http/webrick/rest.rb index 08d459a45..f0c4f6b32 100644 --- a/lib/puppet/network/http/webrick/rest.rb +++ b/lib/puppet/network/http/webrick/rest.rb @@ -1,112 +1,113 @@ require 'puppet/network/http/handler' +require 'puppet/network/http/api/v3' require 'resolv' require 'webrick' require 'webrick/httputils' require 'puppet/util/ssl' class Puppet::Network::HTTP::WEBrickREST < WEBrick::HTTPServlet::AbstractServlet include Puppet::Network::HTTP::Handler def self.mutex @mutex ||= Mutex.new end def initialize(server) raise ArgumentError, "server is required" unless server - register([Puppet::Network::HTTP::API::V2.routes, Puppet::Network::HTTP::API::V1.routes]) + register([Puppet::Network::HTTP::API::V3.routes, Puppet::Network::HTTP::API::V2.routes]) super(server) end # Retrieve the request parameters, including authentication information. def params(request) query = request.query || {} params = if request.request_method == "PUT" # webrick doesn't look at the query string for PUT requests, it only # looks at the body, and then only if the body has a content type that # looks like url-encoded form data. We need the query string data as well. WEBrick::HTTPUtils.parse_query(request.query_string).merge(query) else query end params = Hash[params.collect do |key, value| all_values = value.list [key, all_values.length == 1 ? value : all_values] end] params = decode_params(params) params.merge(client_information(request)) end # WEBrick uses a service method to respond to requests. Simply delegate to # the handler response method. def service(request, response) self.class.mutex.synchronize do process(request, response) end end def headers(request) result = {} request.each do |k, v| result[k.downcase] = v end result end def http_method(request) request.request_method end def path(request) request.path end def body(request) request.body end def client_cert(request) if cert = request.client_cert Puppet::SSL::Certificate.from_instance(cert) else nil end end # Set the specified format as the content type of the response. def set_content_type(response, format) response["content-type"] = format_to_mime(format) end def set_response(response, result, status = 200) response.status = status if status >= 200 and status != 304 response.body = result response["content-length"] = result.stat.size if result.is_a?(File) end end # Retrieve node/cert/ip information from the request object. def client_information(request) result = {} if peer = request.peeraddr and ip = peer[3] result[:ip] = ip end # If they have a certificate (which will almost always be true) # then we get the hostname from the cert, instead of via IP # info result[:authenticated] = false if cert = request.client_cert and cn = Puppet::Util::SSL.cn_from_subject(cert.subject) result[:node] = cn result[:authenticated] = true else result[:node] = resolve_node(result) end result end end diff --git a/lib/puppet/type/file/content.rb b/lib/puppet/type/file/content.rb index 7f92e7500..61b1917b0 100644 --- a/lib/puppet/type/file/content.rb +++ b/lib/puppet/type/file/content.rb @@ -1,241 +1,242 @@ require 'net/http' require 'uri' require 'tempfile' require 'puppet/util/checksums' require 'puppet/network/http' +require 'puppet/network/http/api/v3' require 'puppet/network/http/compression' module Puppet Puppet::Type.type(:file).newproperty(:content) do include Puppet::Util::Diff include Puppet::Util::Checksums include Puppet::Network::HTTP::Compression.module attr_reader :actual_content desc <<-'EOT' The desired contents of a file, as a string. This attribute is mutually exclusive with `source` and `target`. Newlines and tabs can be specified in double-quoted strings using standard escaped syntax --- \n for a newline, and \t for a tab. With very small files, you can construct content strings directly in the manifest... define resolve(nameserver1, nameserver2, domain, search) { $str = "search $search domain $domain nameserver $nameserver1 nameserver $nameserver2 " file { "/etc/resolv.conf": content => "$str", } } ...but for larger files, this attribute is more useful when combined with the [template](http://docs.puppetlabs.com/references/latest/function.html#template) or [file](http://docs.puppetlabs.com/references/latest/function.html#file) function. EOT # Store a checksum as the value, rather than the actual content. # Simplifies everything. munge do |value| if value == :absent value elsif checksum?(value) # XXX This is potentially dangerous because it means users can't write a file whose # entire contents are a plain checksum value else @actual_content = value resource.parameter(:checksum).sum(value) end end # Checksums need to invert how changes are printed. def change_to_s(currentvalue, newvalue) # Our "new" checksum value is provided by the source. if source = resource.parameter(:source) and tmp = source.checksum newvalue = tmp end if currentvalue == :absent return "defined content as '#{newvalue}'" elsif newvalue == :absent return "undefined content from '#{currentvalue}'" else return "content changed '#{currentvalue}' to '#{newvalue}'" end end def checksum_type if source = resource.parameter(:source) result = source.checksum else result = resource[:checksum] end if result =~ /^\{(\w+)\}.+/ return $1.to_sym else return result end end def length (actual_content and actual_content.length) || 0 end def content self.should end # Override this method to provide diffs if asked for. # Also, fix #872: when content is used, and replace is true, the file # should be insync when it exists def insync?(is) if resource.should_be_file? return false if is == :absent else if resource[:ensure] == :present and resource[:content] and s = resource.stat resource.warning "Ensure set to :present but file type is #{s.ftype} so no content will be synced" end return true end return true if ! @resource.replace? result = super if ! result and Puppet[:show_diff] and resource.show_diff? write_temporarily do |path| send @resource[:loglevel], "\n" + diff(@resource[:path], path) end end result end def retrieve return :absent unless stat = @resource.stat ftype = stat.ftype # Don't even try to manage the content on directories or links return nil if ["directory","link"].include?(ftype) begin resource.parameter(:checksum).sum_file(resource[:path]) rescue => detail raise Puppet::Error, "Could not read #{ftype} #{@resource.title}: #{detail}", detail.backtrace end end # Make sure we're also managing the checksum property. def should=(value) # treat the value as a bytestring, in Ruby versions that support it, regardless of the encoding # in which it has been supplied value = value.dup.force_encoding(Encoding::ASCII_8BIT) if value.respond_to?(:force_encoding) @resource.newattr(:checksum) unless @resource.parameter(:checksum) super end # Just write our content out to disk. def sync return_event = @resource.stat ? :file_changed : :file_created # We're safe not testing for the 'source' if there's no 'should' # because we wouldn't have gotten this far if there weren't at least # one valid value somewhere. @resource.write(:content) return_event end def write_temporarily tempfile = Tempfile.new("puppet-file") tempfile.open write(tempfile) tempfile.close yield tempfile.path tempfile.delete end def write(file) resource.parameter(:checksum).sum_stream { |sum| each_chunk_from(actual_content || resource.parameter(:source)) { |chunk| sum << chunk file.print chunk } } end # the content is munged so if it's a checksum source_or_content is nil # unless the checksum indirectly comes from source def each_chunk_from(source_or_content) if source_or_content.is_a?(String) yield source_or_content elsif content_is_really_a_checksum? && source_or_content.nil? yield read_file_from_filebucket elsif source_or_content.nil? yield '' elsif Puppet[:default_file_terminus] == :file_server yield source_or_content.content elsif source_or_content.local? chunk_file_from_disk(source_or_content) { |chunk| yield chunk } else chunk_file_from_source(source_or_content) { |chunk| yield chunk } end end private def content_is_really_a_checksum? checksum?(should) end def chunk_file_from_disk(source_or_content) File.open(source_or_content.full_path, "rb") do |src| while chunk = src.read(8192) yield chunk end end end def get_from_source(source_or_content, &block) source = source_or_content.metadata.source request = Puppet::Indirector::Request.new(:file_content, :find, source, nil, :environment => resource.catalog.environment_instance) request.do_request(:fileserver) do |req| connection = Puppet::Network::HttpPool.http_instance(req.server, req.port) - connection.request_get(Puppet::Network::HTTP::API::V1.request_to_uri(req), add_accept_encoding({"Accept" => "raw"}), &block) + connection.request_get(Puppet::Network::HTTP::API::V3.request_to_uri(req), add_accept_encoding({"Accept" => "raw"}), &block) end end def chunk_file_from_source(source_or_content) get_from_source(source_or_content) do |response| case response.code when /^2/; uncompress(response) { |uncompressor| response.read_body { |chunk| yield uncompressor.uncompress(chunk) } } else # Raise the http error if we didn't get a 'success' of some kind. message = "Error #{response.code} on SERVER: #{(response.body||'').empty? ? response.message : uncompress_body(response)}" raise Net::HTTPError.new(message, response) end end end def read_file_from_filebucket raise "Could not get filebucket from file" unless dipper = resource.bucket sum = should.sub(/\{\w+\}/, '') dipper.getfile(sum) rescue => detail self.fail Puppet::Error, "Could not retrieve content for #{should} from filebucket: #{detail}", detail end end end diff --git a/spec/unit/indirector/request_spec.rb b/spec/unit/indirector/request_spec.rb index 4293140f6..30664904b 100755 --- a/spec/unit/indirector/request_spec.rb +++ b/spec/unit/indirector/request_spec.rb @@ -1,501 +1,490 @@ #! /usr/bin/env ruby require 'spec_helper' require 'matchers/json' require 'puppet/indirector/request' describe Puppet::Indirector::Request do include JSONMatchers describe "when initializing" do it "should always convert the indirection name to a symbol" do Puppet::Indirector::Request.new("ind", :method, "mykey", nil).indirection_name.should == :ind end it "should use provided value as the key if it is a string" do Puppet::Indirector::Request.new(:ind, :method, "mykey", nil).key.should == "mykey" end it "should use provided value as the key if it is a symbol" do Puppet::Indirector::Request.new(:ind, :method, :mykey, nil).key.should == :mykey end it "should use the name of the provided instance as its key if an instance is provided as the key instead of a string" do instance = mock 'instance', :name => "mykey" request = Puppet::Indirector::Request.new(:ind, :method, nil, instance) request.key.should == "mykey" request.instance.should equal(instance) end it "should support options specified as a hash" do expect { Puppet::Indirector::Request.new(:ind, :method, :key, nil, :one => :two) }.to_not raise_error end it "should support nil options" do expect { Puppet::Indirector::Request.new(:ind, :method, :key, nil, nil) }.to_not raise_error end it "should support unspecified options" do expect { Puppet::Indirector::Request.new(:ind, :method, :key, nil) }.to_not raise_error end it "should use an empty options hash if nil was provided" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, nil).options.should == {} end it "should default to a nil node" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).node.should be_nil end it "should set its node attribute if provided in the options" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :node => "foo.com").node.should == "foo.com" end it "should default to a nil ip" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).ip.should be_nil end it "should set its ip attribute if provided in the options" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :ip => "192.168.0.1").ip.should == "192.168.0.1" end it "should default to being unauthenticated" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).should_not be_authenticated end it "should set be marked authenticated if configured in the options" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :authenticated => "eh").should be_authenticated end it "should keep its options as a hash even if a node is specified" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :node => "eh").options.should be_instance_of(Hash) end it "should keep its options as a hash even if another option is specified" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :foo => "bar").options.should be_instance_of(Hash) end it "should treat options other than :ip, :node, and :authenticated as options rather than attributes" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :server => "bar").options[:server].should == "bar" end it "should normalize options to use symbols as keys" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, "foo" => "bar").options[:foo].should == "bar" end describe "and the request key is a URI" do let(:file) { File.expand_path("/my/file with spaces") } let(:an_environment) { Puppet::Node::Environment.create(:an_environment, []) } let(:env_loaders) { Puppet::Environments::Static.new(an_environment) } around(:each) do |example| Puppet.override({ :environments => env_loaders }, "Static environment loader for specs") do example.run end end describe "and the URI is a 'file' URI" do before do @request = Puppet::Indirector::Request.new(:ind, :method, "#{URI.unescape(Puppet::Util.path_to_uri(file).to_s)}", nil) end it "should set the request key to the unescaped full file path" do @request.key.should == file end it "should not set the protocol" do @request.protocol.should be_nil end it "should not set the port" do @request.port.should be_nil end it "should not set the server" do @request.server.should be_nil end end it "should set the protocol to the URI scheme" do Puppet::Indirector::Request.new(:ind, :method, "http://host/an_environment", nil).protocol.should == "http" end it "should set the server if a server is provided" do Puppet::Indirector::Request.new(:ind, :method, "http://host/an_environment", nil).server.should == "host" end it "should set the server and port if both are provided" do Puppet::Indirector::Request.new(:ind, :method, "http://host:543/an_environment", nil).port.should == 543 end it "should default to the masterport if the URI scheme is 'puppet'" do Puppet[:masterport] = "321" Puppet::Indirector::Request.new(:ind, :method, "puppet://host/an_environment", nil).port.should == 321 end it "should use the provided port if the URI scheme is not 'puppet'" do Puppet::Indirector::Request.new(:ind, :method, "http://host/an_environment", nil).port.should == 80 end it "should set the request key to the unescaped key part path from the URI" do Puppet::Indirector::Request.new(:ind, :method, "http://host/an_environment/terminus/stuff with spaces", nil).key.should == "stuff with spaces" end it "should set the :uri attribute to the full URI" do Puppet::Indirector::Request.new(:ind, :method, "http:///an_environment/stu ff", nil).uri.should == 'http:///an_environment/stu ff' end it "should not parse relative URI" do Puppet::Indirector::Request.new(:ind, :method, "foo/bar", nil).uri.should be_nil end it "should not parse opaque URI" do Puppet::Indirector::Request.new(:ind, :method, "mailto:joe", nil).uri.should be_nil end end it "should allow indication that it should not read a cached instance" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :ignore_cache => true).should be_ignore_cache end it "should default to not ignoring the cache" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).should_not be_ignore_cache end it "should allow indication that it should not not read an instance from the terminus" do Puppet::Indirector::Request.new(:ind, :method, :key, nil, :ignore_terminus => true).should be_ignore_terminus end it "should default to not ignoring the terminus" do Puppet::Indirector::Request.new(:ind, :method, :key, nil).should_not be_ignore_terminus end end it "should look use the Indirection class to return the appropriate indirection" do ind = mock 'indirection' Puppet::Indirector::Indirection.expects(:instance).with(:myind).returns ind request = Puppet::Indirector::Request.new(:myind, :method, :key, nil) request.indirection.should equal(ind) end it "should use its indirection to look up the appropriate model" do ind = mock 'indirection' Puppet::Indirector::Indirection.expects(:instance).with(:myind).returns ind request = Puppet::Indirector::Request.new(:myind, :method, :key, nil) ind.expects(:model).returns "mymodel" request.model.should == "mymodel" end it "should fail intelligently when asked to find a model but the indirection cannot be found" do Puppet::Indirector::Indirection.expects(:instance).with(:myind).returns nil request = Puppet::Indirector::Request.new(:myind, :method, :key, nil) expect { request.model }.to raise_error(ArgumentError) end it "should have a method for determining if the request is plural or singular" do Puppet::Indirector::Request.new(:myind, :method, :key, nil).should respond_to(:plural?) end it "should be considered plural if the method is 'search'" do Puppet::Indirector::Request.new(:myind, :search, :key, nil).should be_plural end it "should not be considered plural if the method is not 'search'" do Puppet::Indirector::Request.new(:myind, :find, :key, nil).should_not be_plural end - it "should use its uri, if it has one, as its string representation" do - Puppet.override({ - :environments => Puppet::Environments::Static.new( - Puppet::Node::Environment.create(:baz, []) - )}, - "Static loader for spec") do - - Puppet::Indirector::Request.new(:myind, :find, "foo://bar/baz", nil).to_s.should == "foo://bar/baz" - end - end - it "should use its indirection name and key, if it has no uri, as its string representation" do Puppet::Indirector::Request.new(:myind, :find, "key", nil) == "/myind/key" end it "should be able to return the URI-escaped key" do Puppet::Indirector::Request.new(:myind, :find, "my key", nil).escaped_key.should == URI.escape("my key") end it "should set its environment to an environment instance when a string is specified as its environment" do env = Puppet::Node::Environment.create(:foo, []) Puppet.override(:environments => Puppet::Environments::Static.new(env)) do Puppet::Indirector::Request.new(:myind, :find, "my key", nil, :environment => "foo").environment.should == env end end it "should use any passed in environment instances as its environment" do env = Puppet::Node::Environment.create(:foo, []) Puppet::Indirector::Request.new(:myind, :find, "my key", nil, :environment => env).environment.should equal(env) end it "should use the current environment when none is provided" do configured = Puppet::Node::Environment.create(:foo, []) Puppet[:environment] = "foo" expect(Puppet::Indirector::Request.new(:myind, :find, "my key", nil).environment).to eq(Puppet.lookup(:current_environment)) end it "should support converting its options to a hash" do Puppet::Indirector::Request.new(:myind, :find, "my key", nil ).should respond_to(:to_hash) end it "should include all of its attributes when its options are converted to a hash" do Puppet::Indirector::Request.new(:myind, :find, "my key", nil, :node => 'foo').to_hash[:node].should == 'foo' end describe "when building a query string from its options" do def a_request_with_options(options) Puppet::Indirector::Request.new(:myind, :find, "my key", nil, options) end def the_parsed_query_string_from(request) CGI.parse(request.query_string.sub(/^\?/, '')) end it "should return an empty query string if there are no options" do request = a_request_with_options(nil) request.query_string.should == "" end it "should return an empty query string if the options are empty" do request = a_request_with_options({}) request.query_string.should == "" end it "should include all options in the query string, separated by '&'" do request = a_request_with_options(:one => "two", :three => "four") the_parsed_query_string_from(request).should == { "one" => ["two"], "three" => ["four"] } end it "should ignore nil options" do request = a_request_with_options(:one => "two", :three => nil) the_parsed_query_string_from(request).should == { "one" => ["two"] } end it "should convert 'true' option values into strings" do request = a_request_with_options(:one => true) the_parsed_query_string_from(request).should == { "one" => ["true"] } end it "should convert 'false' option values into strings" do request = a_request_with_options(:one => false) the_parsed_query_string_from(request).should == { "one" => ["false"] } end it "should convert to a string all option values that are integers" do request = a_request_with_options(:one => 50) the_parsed_query_string_from(request).should == { "one" => ["50"] } end it "should convert to a string all option values that are floating point numbers" do request = a_request_with_options(:one => 1.2) the_parsed_query_string_from(request).should == { "one" => ["1.2"] } end it "should CGI-escape all option values that are strings" do request = a_request_with_options(:one => "one two") the_parsed_query_string_from(request).should == { "one" => ["one two"] } end it "should convert an array of values into multiple entries for the same key" do request = a_request_with_options(:one => %w{one two}) the_parsed_query_string_from(request).should == { "one" => ["one", "two"] } end it "should stringify simple data types inside an array" do request = a_request_with_options(:one => ['one', nil]) the_parsed_query_string_from(request).should == { "one" => ["one"] } end it "should error if an array contains another array" do request = a_request_with_options(:one => ['one', ["not allowed"]]) expect { request.query_string }.to raise_error(ArgumentError) end it "should error if an array contains illegal data" do request = a_request_with_options(:one => ['one', { :not => "allowed" }]) expect { request.query_string }.to raise_error(ArgumentError) end it "should convert to a string and CGI-escape all option values that are symbols" do request = a_request_with_options(:one => :"sym bol") the_parsed_query_string_from(request).should == { "one" => ["sym bol"] } end it "should fail if options other than booleans or strings are provided" do request = a_request_with_options(:one => { :one => :two }) expect { request.query_string }.to raise_error(ArgumentError) end end context '#do_request' do before :each do @request = Puppet::Indirector::Request.new(:myind, :find, "my key", nil) end context 'when not using SRV records' do before :each do Puppet.settings[:use_srv_records] = false end it "yields the request with the default server and port when no server or port were specified on the original request" do count = 0 rval = @request.do_request(:puppet, 'puppet.example.com', '90210') do |got| count += 1 got.server.should == 'puppet.example.com' got.port.should == '90210' 'Block return value' end count.should == 1 rval.should == 'Block return value' end end context 'when using SRV records' do before :each do Puppet.settings[:use_srv_records] = true Puppet.settings[:srv_domain] = 'example.com' end it "yields the request with the original server and port unmodified" do @request.server = 'puppet.example.com' @request.port = '90210' count = 0 rval = @request.do_request do |got| count += 1 got.server.should == 'puppet.example.com' got.port.should == '90210' 'Block return value' end count.should == 1 rval.should == 'Block return value' end context "when SRV returns servers" do before :each do @dns_mock = mock('dns') Resolv::DNS.expects(:new).returns(@dns_mock) @port = 7205 @host = '_x-puppet._tcp.example.com' @srv_records = [Resolv::DNS::Resource::IN::SRV.new(0, 0, @port, @host)] @dns_mock.expects(:getresources). with("_x-puppet._tcp.#{Puppet.settings[:srv_domain]}", Resolv::DNS::Resource::IN::SRV). returns(@srv_records) end it "yields a request using the server and port from the SRV record" do count = 0 rval = @request.do_request do |got| count += 1 got.server.should == '_x-puppet._tcp.example.com' got.port.should == 7205 @block_return end count.should == 1 rval.should == @block_return end it "should fall back to the default server when the block raises a SystemCallError" do count = 0 second_pass = nil rval = @request.do_request(:puppet, 'puppet', 8140) do |got| count += 1 if got.server == '_x-puppet._tcp.example.com' then raise SystemCallError, "example failure" else second_pass = got end @block_return end second_pass.server.should == 'puppet' second_pass.port.should == 8140 count.should == 2 rval.should == @block_return end end end end describe "#remote?" do def request(options = {}) Puppet::Indirector::Request.new('node', 'find', 'localhost', nil, options) end it "should not be unless node or ip is set" do request.should_not be_remote end it "should be remote if node is set" do request(:node => 'example.com').should be_remote end it "should be remote if ip is set" do request(:ip => '127.0.0.1').should be_remote end it "should be remote if node and ip are set" do request(:node => 'example.com', :ip => '127.0.0.1').should be_remote end end end diff --git a/spec/unit/indirector/rest_spec.rb b/spec/unit/indirector/rest_spec.rb index e3b71fa2d..5345a80d4 100755 --- a/spec/unit/indirector/rest_spec.rb +++ b/spec/unit/indirector/rest_spec.rb @@ -1,574 +1,573 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/indirector' require 'puppet/indirector/errors' require 'puppet/indirector/rest' require 'puppet/util/psych_support' HTTP_ERROR_CODES = [300, 400, 500] # Just one from each category since the code makes no real distinctions shared_examples_for "a REST terminus method" do |terminus_method| HTTP_ERROR_CODES.each do |code| describe "when the response code is #{code}" do let(:response) { mock_response(code, 'error messaged!!!') } it "raises an http error with the body of the response" do expect { terminus.send(terminus_method, request) }.to raise_error(Net::HTTPError, "Error #{code} on SERVER: #{response.body}") end it "does not attempt to deserialize the response" do model.expects(:convert_from).never expect { terminus.send(terminus_method, request) }.to raise_error(Net::HTTPError) end # I'm not sure what this means or if it's used it "if the body is empty raises an http error with the response header" do response.stubs(:body).returns "" response.stubs(:message).returns "fhqwhgads" expect { terminus.send(terminus_method, request) }.to raise_error(Net::HTTPError, "Error #{code} on SERVER: #{response.message}") end describe "and the body is compressed" do it "raises an http error with the decompressed body of the response" do uncompressed_body = "why" compressed_body = Zlib::Deflate.deflate(uncompressed_body) response = mock_response(code, compressed_body, 'text/plain', 'deflate') connection.expects(http_method).returns(response) expect { terminus.send(terminus_method, request) }.to raise_error(Net::HTTPError, "Error #{code} on SERVER: #{uncompressed_body}") end end end end end shared_examples_for "a deserializing terminus method" do |terminus_method| describe "when the response has no content-type" do let(:response) { mock_response(200, "body", nil, nil) } it "raises an error" do expect { terminus.send(terminus_method, request) }.to raise_error(RuntimeError, "No content type in http response; cannot parse") end end it "doesn't catch errors in deserialization" do model.expects(:convert_from).raises(Puppet::Error, "Whoa there") expect { terminus.send(terminus_method, request) }.to raise_error(Puppet::Error, "Whoa there") end end describe Puppet::Indirector::REST do before :all do class Puppet::TestModel include Puppet::Util::PsychSupport extend Puppet::Indirector indirects :test_model attr_accessor :name, :data def initialize(name = "name", data = '') @name = name @data = data end def self.convert_from(format, string) new('', string) end def self.convert_from_multiple(format, string) string.split(',').collect { |s| convert_from(format, s) } end def to_data_hash { 'name' => @name, 'data' => @data } end def ==(other) other.is_a? Puppet::TestModel and other.name == name and other.data == data end end # The subclass must not be all caps even though the superclass is class Puppet::TestModel::Rest < Puppet::Indirector::REST end Puppet::TestModel.indirection.terminus_class = :rest end after :all do Puppet::TestModel.indirection.delete # Remove the class, unlinking it from the rest of the system. Puppet.send(:remove_const, :TestModel) end let(:terminus_class) { Puppet::TestModel::Rest } let(:terminus) { Puppet::TestModel.indirection.terminus(:rest) } let(:indirection) { Puppet::TestModel.indirection } let(:model) { Puppet::TestModel } around(:each) do |example| Puppet.override(:current_environment => Puppet::Node::Environment.create(:production, [])) do example.run end end def mock_response(code, body, content_type='text/plain', encoding=nil) obj = stub('http 200 ok', :code => code.to_s, :body => body) obj.stubs(:[]).with('content-type').returns(content_type) obj.stubs(:[]).with('content-encoding').returns(encoding) obj.stubs(:[]).with(Puppet::Network::HTTP::HEADER_PUPPET_VERSION).returns(Puppet.version) obj end def find_request(key, options={}) Puppet::Indirector::Request.new(:test_model, :find, key, nil, options) end def head_request(key, options={}) Puppet::Indirector::Request.new(:test_model, :head, key, nil, options) end def search_request(key, options={}) Puppet::Indirector::Request.new(:test_model, :search, key, nil, options) end def delete_request(key, options={}) Puppet::Indirector::Request.new(:test_model, :destroy, key, nil, options) end def save_request(key, instance, options={}) Puppet::Indirector::Request.new(:test_model, :save, key, instance, options) end it "should have a method for specifying what setting a subclass should use to retrieve its server" do terminus_class.should respond_to(:use_server_setting) end it "should use any specified setting to pick the server" do terminus_class.expects(:server_setting).returns :ca_server Puppet[:ca_server] = "myserver" terminus_class.server.should == "myserver" end it "should default to :server for the server setting" do terminus_class.expects(:server_setting).returns nil Puppet[:server] = "myserver" terminus_class.server.should == "myserver" end it "should have a method for specifying what setting a subclass should use to retrieve its port" do terminus_class.should respond_to(:use_port_setting) end it "should use any specified setting to pick the port" do terminus_class.expects(:port_setting).returns :ca_port Puppet[:ca_port] = "321" terminus_class.port.should == 321 end it "should default to :port for the port setting" do terminus_class.expects(:port_setting).returns nil Puppet[:masterport] = "543" terminus_class.port.should == 543 end it 'should default to :puppet for the srv_service' do Puppet::Indirector::REST.srv_service.should == :puppet end describe "when creating an HTTP client" do it "should use the class's server and port if the indirection request provides neither" do @request = stub 'request', :key => "foo", :server => nil, :port => nil terminus.class.expects(:port).returns 321 terminus.class.expects(:server).returns "myserver" Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" terminus.network(@request).should == "myconn" end it "should use the server from the indirection request if one is present" do @request = stub 'request', :key => "foo", :server => "myserver", :port => nil terminus.class.stubs(:port).returns 321 Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" terminus.network(@request).should == "myconn" end it "should use the port from the indirection request if one is present" do @request = stub 'request', :key => "foo", :server => nil, :port => 321 terminus.class.stubs(:server).returns "myserver" Puppet::Network::HttpPool.expects(:http_instance).with("myserver", 321).returns "myconn" terminus.network(@request).should == "myconn" end end describe "#find" do let(:http_method) { :get } let(:response) { mock_response(200, 'body') } let(:connection) { stub('mock http connection', :get => response, :verify_callback= => nil) } let(:request) { find_request('foo') } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :find it_behaves_like 'a deserializing terminus method', :find describe "with a long set of parameters" do it "calls post on the connection with the query params in the body" do params = {} 'aa'.upto('zz') do |s| params[s] = 'foo' end # The request special-cases this parameter, and it # won't be passed on to the server, so we remove it here # to avoid a failure. params.delete('ip') params["environment"] = "production" request = find_request('whoa', params) connection.expects(:post).with do |uri, body| body.split("&").sort == params.map {|key,value| "#{key}=#{value}"}.sort end.returns(mock_response(200, 'body')) terminus.find(request) end end describe "with no parameters" do it "calls get on the connection" do request = find_request('foo bar') - connection.expects(:get).with('/test_model/foo%20bar?environment=production&', anything).returns(mock_response('200', 'response body')) + connection.expects(:get).with('/v3/test_model/foo%20bar?environment=production&', anything).returns(mock_response('200', 'response body')) terminus.find(request).should == model.new('foo bar', 'response body') end end it "returns nil on 404" do response = mock_response('404', nil) connection.expects(:get).returns(response) terminus.find(request).should == nil end it 'raises no warning for a 404 (when not asked to do so)' do response = mock_response('404', 'this is the notfound you are looking for') connection.expects(:get).returns(response) - expected_message = 'Find /production/test_model/foo? resulted in 404 with the message: this is the notfound you are looking for' expect{terminus.find(request)}.to_not raise_error() end context 'when fail_on_404 is used in request' do it 'raises an error for a 404 when asked to do so' do request = find_request('foo', :fail_on_404 => true) response = mock_response('404', 'this is the notfound you are looking for') connection.expects(:get).returns(response) expect do terminus.find(request) end.to raise_error( Puppet::Error, - 'Find /test_model/foo?environment=production&fail_on_404=true resulted in 404 with the message: this is the notfound you are looking for') + 'Find /v3/test_model/foo?environment=production&fail_on_404=true resulted in 404 with the message: this is the notfound you are looking for') end it 'truncates the URI when it is very long' do request = find_request('foo', :fail_on_404 => true, :long_param => ('A' * 100) + 'B') response = mock_response('404', 'this is the notfound you are looking for') connection.expects(:get).returns(response) expect do terminus.find(request) end.to raise_error( Puppet::Error, /\/test_model\/foo.*\?environment=production&.*long_param=A+\.\.\..*resulted in 404 with the message/) end it 'does not truncate the URI when logging debug information' do Puppet.debug = true request = find_request('foo', :fail_on_404 => true, :long_param => ('A' * 100) + 'B') response = mock_response('404', 'this is the notfound you are looking for') connection.expects(:get).returns(response) expect do terminus.find(request) end.to raise_error( Puppet::Error, /\/test_model\/foo.*\?environment=production&.*long_param=A+B.*resulted in 404 with the message/) end end it "asks the model to deserialize the response body and sets the name on the resulting object to the find key" do connection.expects(:get).returns response model.expects(:convert_from).with(response['content-type'], response.body).returns( model.new('overwritten', 'decoded body') ) terminus.find(request).should == model.new('foo', 'decoded body') end it "doesn't require the model to support name=" do connection.expects(:get).returns response instance = model.new('name', 'decoded body') model.expects(:convert_from).with(response['content-type'], response.body).returns(instance) instance.expects(:respond_to?).with(:name=).returns(false) instance.expects(:name=).never terminus.find(request).should == model.new('name', 'decoded body') end it "provides an Accept header containing the list of supported formats joined with commas" do connection.expects(:get).with(anything, has_entry("Accept" => "supported, formats")).returns(response) terminus.model.expects(:supported_formats).returns %w{supported formats} terminus.find(request) end it "adds an Accept-Encoding header" do terminus.expects(:add_accept_encoding).returns({"accept-encoding" => "gzip"}) connection.expects(:get).with(anything, has_entry("accept-encoding" => "gzip")).returns(response) terminus.find(request) end it "uses only the mime-type from the content-type header when asking the model to deserialize" do response = mock_response('200', 'mydata', "text/plain; charset=utf-8") connection.expects(:get).returns(response) model.expects(:convert_from).with("text/plain", "mydata").returns "myobject" terminus.find(request).should == "myobject" end it "decompresses the body before passing it to the model for deserialization" do uncompressed_body = "Why hello there" compressed_body = Zlib::Deflate.deflate(uncompressed_body) response = mock_response('200', compressed_body, 'text/plain', 'deflate') connection.expects(:get).returns(response) model.expects(:convert_from).with("text/plain", uncompressed_body).returns "myobject" terminus.find(request).should == "myobject" end end describe "#head" do let(:http_method) { :head } let(:response) { mock_response(200, nil) } let(:connection) { stub('mock http connection', :head => response, :verify_callback= => nil) } let(:request) { head_request('foo') } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :head it "returns true if there was a successful http response" do connection.expects(:head).returns mock_response('200', nil) terminus.head(request).should == true end it "returns false on a 404 response" do connection.expects(:head).returns mock_response('404', nil) terminus.head(request).should == false end end describe "#search" do let(:http_method) { :get } let(:response) { mock_response(200, 'data1,data2,data3') } let(:connection) { stub('mock http connection', :get => response, :verify_callback= => nil) } let(:request) { search_request('foo') } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :search it_behaves_like 'a deserializing terminus method', :search it "should call the GET http method on a network connection" do - connection.expects(:get).with('/test_models/foo?environment=production&', has_key('Accept')).returns mock_response(200, 'data3, data4') + connection.expects(:get).with('/v3/test_models/foo?environment=production&', has_key('Accept')).returns mock_response(200, 'data3, data4') terminus.search(request) end it "returns an empty list on 404" do response = mock_response('404', nil) connection.expects(:get).returns(response) terminus.search(request).should == [] end it "asks the model to deserialize the response body into multiple instances" do terminus.search(request).should == [model.new('', 'data1'), model.new('', 'data2'), model.new('', 'data3')] end it "should provide an Accept header containing the list of supported formats joined with commas" do connection.expects(:get).with(anything, has_entry("Accept" => "supported, formats")).returns(mock_response(200, '')) terminus.model.expects(:supported_formats).returns %w{supported formats} terminus.search(request) end it "should return an empty array if serialization returns nil" do model.stubs(:convert_from_multiple).returns nil terminus.search(request).should == [] end end describe "#destroy" do let(:http_method) { :delete } let(:response) { mock_response(200, 'body') } let(:connection) { stub('mock http connection', :delete => response, :verify_callback= => nil) } let(:request) { delete_request('foo') } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :destroy it_behaves_like 'a deserializing terminus method', :destroy it "should call the DELETE http method on a network connection" do - connection.expects(:delete).with('/test_model/foo?environment=production&', has_key('Accept')).returns(response) + connection.expects(:delete).with('/v3/test_model/foo?environment=production&', has_key('Accept')).returns(response) terminus.destroy(request) end it "should fail if any options are provided, since DELETE apparently does not support query options" do request = delete_request('foo', :one => "two", :three => "four") expect { terminus.destroy(request) }.to raise_error(ArgumentError) end it "should deserialize and return the http response" do connection.expects(:delete).returns response terminus.destroy(request).should == model.new('', 'body') end it "returns nil on 404" do response = mock_response('404', nil) connection.expects(:delete).returns(response) terminus.destroy(request).should == nil end it "should provide an Accept header containing the list of supported formats joined with commas" do connection.expects(:delete).with(anything, has_entry("Accept" => "supported, formats")).returns(response) terminus.model.expects(:supported_formats).returns %w{supported formats} terminus.destroy(request) end end describe "#save" do let(:http_method) { :put } let(:response) { mock_response(200, 'body') } let(:connection) { stub('mock http connection', :put => response, :verify_callback= => nil) } let(:instance) { model.new('the thing', 'some contents') } let(:request) { save_request(instance.name, instance) } before :each do terminus.stubs(:network).returns(connection) end it_behaves_like 'a REST terminus method', :save it "should call the PUT http method on a network connection" do - connection.expects(:put).with('/test_model/the%20thing?environment=production&', anything, has_key("Content-Type")).returns response + connection.expects(:put).with('/v3/test_model/the%20thing?environment=production&', anything, has_key("Content-Type")).returns response terminus.save(request) end it "should fail if any options are provided, since PUT apparently does not support query options" do request = save_request(instance.name, instance, :one => "two", :three => "four") expect { terminus.save(request) }.to raise_error(ArgumentError) end it "should serialize the instance using the default format and pass the result as the body of the request" do instance.expects(:render).returns "serial_instance" connection.expects(:put).with(anything, "serial_instance", anything).returns response terminus.save(request) end it "returns nil on 404" do response = mock_response('404', nil) connection.expects(:put).returns(response) terminus.save(request).should == nil end it "returns nil" do connection.expects(:put).returns response terminus.save(request).should be_nil end it "should provide an Accept header containing the list of supported formats joined with commas" do connection.expects(:put).with(anything, anything, has_entry("Accept" => "supported, formats")).returns(response) instance.expects(:render).returns('') model.expects(:supported_formats).returns %w{supported formats} instance.expects(:mime).returns "supported" terminus.save(request) end it "should provide a Content-Type header containing the mime-type of the sent object" do instance.expects(:mime).returns "mime" connection.expects(:put).with(anything, anything, has_entry('Content-Type' => "mime")).returns(response) terminus.save(request) end end context 'dealing with SRV settings' do [ :destroy, :find, :head, :save, :search ].each do |method| it "##{method} passes the SRV service, and fall-back server & port to the request's do_request method" do request = Puppet::Indirector::Request.new(:indirection, method, 'key', nil) stub_response = mock_response('200', 'body') request.expects(:do_request).with(terminus.class.srv_service, terminus.class.server, terminus.class.port).returns(stub_response) terminus.send(method, request) end end end end diff --git a/spec/unit/network/authconfig_spec.rb b/spec/unit/network/authconfig_spec.rb index 12ff1c0d3..967754feb 100755 --- a/spec/unit/network/authconfig_spec.rb +++ b/spec/unit/network/authconfig_spec.rb @@ -1,109 +1,109 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/authconfig' describe Puppet::Network::AuthConfig do before :each do Puppet::FileSystem.stubs(:stat).returns stub('stat', :ctime => :now) Time.stubs(:now).returns Time.now Puppet::Network::AuthConfig.any_instance.stubs(:exists?).returns(true) # FIXME @authconfig = Puppet::Network::AuthConfig.new("dummy") end describe "when initializing" do it "inserts default ACLs after setting initial rights" do Puppet::Network::AuthConfig.any_instance.expects(:insert_default_acl) Puppet::Network::AuthConfig.new end end describe "when defining an acl with mk_acl" do before :each do Puppet::Network::AuthConfig.any_instance.stubs(:insert_default_acl) @authconfig = Puppet::Network::AuthConfig.new end it "should create a new right for each default acl" do @authconfig.mk_acl(:acl => '/') @authconfig.rights['/'].should be end it "allows everyone for each default right" do @authconfig.mk_acl(:acl => '/') @authconfig.rights['/'].should be_globalallow end it "accepts an argument to restrict the method" do @authconfig.mk_acl(:acl => '/', :method => :find) @authconfig.rights['/'].methods.should == [:find] end it "creates rights with authentication set to true by default" do @authconfig.mk_acl(:acl => '/') @authconfig.rights['/'].authentication.should be_true end it "accepts an argument to set the authentication requirement" do @authconfig.mk_acl(:acl => '/', :authenticated => :any) @authconfig.rights['/'].authentication.should be_false end end describe "when adding default ACLs" do before :each do Puppet::Network::AuthConfig.any_instance.stubs(:insert_default_acl) @authconfig = Puppet::Network::AuthConfig.new Puppet::Network::AuthConfig.any_instance.unstub(:insert_default_acl) end Puppet::Network::AuthConfig::DEFAULT_ACL.each do |acl| it "should create a default right for #{acl[:acl]}" do @authconfig.stubs(:mk_acl) @authconfig.expects(:mk_acl).with(acl) @authconfig.insert_default_acl end end it "should log at info loglevel" do Puppet.expects(:info).at_least_once @authconfig.insert_default_acl end it "creates an empty catch-all rule for '/' for any authentication request state" do @authconfig.stubs(:mk_acl) @authconfig.insert_default_acl @authconfig.rights['/'].should be_empty @authconfig.rights['/'].authentication.should be_false end it '(CVE-2013-2275) allows report submission only for the node matching the certname by default' do acl = { - :acl => "~ ^\/report\/([^\/]+)$", + :acl => "~ ^\/v3\/report\/([^\/]+)$", :method => :save, :allow => '$1', :authenticated => true } @authconfig.stubs(:mk_acl) @authconfig.expects(:mk_acl).with(acl) @authconfig.insert_default_acl end end describe "when checking authorization" do it "should ask for authorization to the ACL subsystem" do params = { :ip => "127.0.0.1", :node => "me", :environment => :env, :authenticated => true } Puppet::Network::Rights.any_instance.expects(:is_request_forbidden_and_why?).with(:save, "/path/to/resource", params) described_class.new.check_authorization(:save, "/path/to/resource", params) end end end diff --git a/spec/unit/network/http/api/v1_spec.rb b/spec/unit/network/http/api/v1_spec.rb deleted file mode 100755 index 50237fa44..000000000 --- a/spec/unit/network/http/api/v1_spec.rb +++ /dev/null @@ -1,486 +0,0 @@ -#! /usr/bin/env ruby -require 'spec_helper' - -require 'puppet/network/http' -require 'puppet/network/http/api/v1' -require 'puppet/indirector_testing' - -describe Puppet::Network::HTTP::API::V1 do - let(:not_found_code) { Puppet::Network::HTTP::Error::HTTPNotFoundError::CODE } - let(:not_acceptable_code) { Puppet::Network::HTTP::Error::HTTPNotAcceptableError::CODE } - let(:not_authorized_code) { Puppet::Network::HTTP::Error::HTTPNotAuthorizedError::CODE } - - let(:indirection) { Puppet::IndirectorTesting.indirection } - let(:handler) { Puppet::Network::HTTP::API::V1.new } - let(:response) { Puppet::Network::HTTP::MemoryResponse.new } - let(:params) { { :environment => "production" } } - - def a_request_that_heads(data, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => "text/pson", }, - :method => "HEAD", - :path => "/#{indirection.name}/#{data.value}", - :params => params, - }) - end - - def a_request_that_submits(data, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => request[:content_type_header] || "text/pson", }, - :method => "PUT", - :path => "/#{indirection.name}/#{data.value}", - :params => params, - :body => request[:body].nil? ? data.render("pson") : request[:body] - }) - end - - def a_request_that_destroys(data, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => "text/pson", }, - :method => "DELETE", - :path => "/#{indirection.name}/#{data.value}", - :params => params, - :body => '' - }) - end - - def a_request_that_finds(data, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => "text/pson", }, - :method => "GET", - :path => "/#{indirection.name}/#{data.value}", - :params => params, - :body => '' - }) - end - - def a_request_that_searches(key, request = {}) - Puppet::Network::HTTP::Request.from_hash({ - :headers => { - 'accept' => request[:accept_header], - 'content-type' => "text/pson", }, - :method => "GET", - :path => "/#{indirection.name}s/#{key}", - :params => params, - :body => '' - }) - end - - - before do - Puppet::IndirectorTesting.indirection.terminus_class = :memory - Puppet::IndirectorTesting.indirection.terminus.clear - handler.stubs(:check_authorization) - handler.stubs(:warn_if_near_expiration) - end - - describe "when converting a URI into a request" do - let(:environment) { Puppet::Node::Environment.create(:env, []) } - let(:env_loaders) { Puppet::Environments::Static.new(environment) } - let(:params) { { :environment => "env" } } - - before do - handler.stubs(:handler).returns "foo" - end - - around do |example| - Puppet.override(:environments => env_loaders) do - example.run - end - end - - it "should get the environment from a query parameter" do - handler.uri2indirection("GET", "/foo/bar", params)[3][:environment].to_s.should == "env" - end - - it "should fail if the environment is not alphanumeric" do - lambda { handler.uri2indirection("GET", "/foo/bar", {:environment => "env ness"}) }.should raise_error(ArgumentError) - end - - it "should not pass a buck_path parameter through (See Bugs #13553, #13518, #13511)" do - handler.uri2indirection("GET", "/foo/bar", { :environment => "env", - :bucket_path => "/malicious/path" })[3].should_not include({ :bucket_path => "/malicious/path" }) - end - - it "should pass allowed parameters through" do - handler.uri2indirection("GET", "/foo/bar", { :environment => "env", - :allowed_param => "value" })[3].should include({ :allowed_param => "value" }) - end - - it "should return the environment as a Puppet::Node::Environment" do - handler.uri2indirection("GET", "/foo/bar", params)[3][:environment].should be_a(Puppet::Node::Environment) - end - - it "should use the first field of the URI as the indirection name" do - handler.uri2indirection("GET", "/foo/bar", params)[0].should == "foo" - end - - it "should fail if the indirection name is not alphanumeric" do - lambda { handler.uri2indirection("GET", "/foo ness/bar", params) }.should raise_error(ArgumentError) - end - - it "should use the remainder of the URI as the indirection key" do - handler.uri2indirection("GET", "/foo/bar", params)[2].should == "bar" - end - - it "should support the indirection key being a /-separated file path" do - handler.uri2indirection("GET", "/foo/bee/baz/bomb", params)[2].should == "bee/baz/bomb" - end - - it "should fail if no indirection key is specified" do - lambda { handler.uri2indirection("GET", "/foo", params) }.should raise_error(ArgumentError) - end - - it "should choose 'find' as the indirection method if the http method is a GET and the indirection name is singular" do - handler.uri2indirection("GET", "/foo/bar", params)[1].should == :find - end - - it "should choose 'find' as the indirection method if the http method is a POST and the indirection name is singular" do - handler.uri2indirection("POST", "/foo/bar", params)[1].should == :find - end - - it "should choose 'head' as the indirection method if the http method is a HEAD and the indirection name is singular" do - handler.uri2indirection("HEAD", "/foo/bar", params)[1].should == :head - end - - it "should choose 'search' as the indirection method if the http method is a GET and the indirection name is plural" do - handler.uri2indirection("GET", "/foos/bar", params)[1].should == :search - end - - it "should change indirection name to 'status' if the http method is a GET and the indirection name is statuses" do - handler.uri2indirection("GET", "/statuses/bar", params)[0].should == "status" - end - - it "should change indirection name to 'node' if the http method is a GET and the indirection name is nodes" do - handler.uri2indirection("GET", "/nodes/bar", params)[0].should == "node" - end - - it "should choose 'delete' as the indirection method if the http method is a DELETE and the indirection name is singular" do - handler.uri2indirection("DELETE", "/foo/bar", params)[1].should == :destroy - end - - it "should choose 'save' as the indirection method if the http method is a PUT and the indirection name is singular" do - handler.uri2indirection("PUT", "/foo/bar", params)[1].should == :save - end - - it "should fail if an indirection method cannot be picked" do - lambda { handler.uri2indirection("UPDATE", "/node/bar", params) }.should raise_error(ArgumentError) - end - - it "should URI unescape the indirection key" do - escaped = URI.escape("foo bar") - indirection, method, key, final_params = handler.uri2indirection("GET", "/node/#{escaped}", params) - key.should == "foo bar" - end - end - - describe "when converting a request into a URI" do - let(:environment) { Puppet::Node::Environment.create(:myenv, []) } - let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } - - before do - handler.stubs(:handler).returns "foo" - end - - it "should include the environment in the query string of the URI" do - handler.class.request_to_uri(request).should == "/foo/with%20spaces?environment=myenv&foo=bar" - end - - it "should pluralize the indirection name if the method is 'search'" do - request.stubs(:method).returns :search - handler.class.request_to_uri(request).split("/")[1].should == "foos" - end - - it "should add the query string to the URI" do - request.expects(:query_string).returns "query" - handler.class.request_to_uri(request).should =~ /\&query$/ - end - end - - describe "when converting a request into a URI with body" do - let(:environment) { Puppet::Node::Environment.create(:myenv, []) } - let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } - - it "should use the indirection as the first field of the URI" do - handler.class.request_to_uri_and_body(request).first.split("/")[1].should == "foo" - end - - it "should use the escaped key as the remainder of the URI" do - escaped = URI.escape("with spaces") - handler.class.request_to_uri_and_body(request).first.split("/")[2].sub(/\?.+/, '').should == escaped - end - - it "should return the URI and body separately" do - handler.class.request_to_uri_and_body(request).should == ["/foo/with%20spaces", "environment=myenv&foo=bar"] - end - end - - describe "when processing a request" do - it "should return not_authorized_code if the request is not authorized" do - request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) - - handler.expects(:check_authorization).raises(Puppet::Network::AuthorizationError.new("forbidden")) - - handler.call(request, response) - - expect(response.code).to eq(not_authorized_code) - end - - it "should return 'not found' if the indirection does not support remote requests" do - request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) - - indirection.expects(:allow_remote_requests?).returns(false) - - handler.call(request, response) - - expect(response.code).to eq(not_found_code) - end - - it "should return 'not found' if the environment does not exist" do - Puppet.override(:environments => Puppet::Environments::Static.new()) do - request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) - - handler.call(request, response) - - expect(response.code).to eq(not_found_code) - end - end - - it "should serialize a controller exception when an exception is thrown while finding the model instance" do - request = a_request_that_finds(Puppet::IndirectorTesting.new("key")) - handler.expects(:do_find).raises(ArgumentError, "The exception") - - handler.call(request, response) - - expect(response.code).to eq(400) - expect(response.body).to eq("The exception") - expect(response.type).to eq("text/plain") - end - end - - describe "when finding a model instance" do - it "uses the first supported format for the response" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_finds(data, :accept_header => "unknown, pson") - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "responds with a not_acceptable_code error when no accept header is provided" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_finds(data, :accept_header => nil) - - handler.call(request, response) - - expect(response.code).to eq(not_acceptable_code) - end - - it "raises an error when no accepted formats are known" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_finds(data, :accept_header => "unknown, also/unknown") - - handler.call(request, response) - - expect(response.code).to eq(not_acceptable_code) - end - - it "should pass the result through without rendering it if the result is a string" do - data = Puppet::IndirectorTesting.new("my data") - data_string = "my data string" - request = a_request_that_finds(data, :accept_header => "text/pson") - indirection.expects(:find).returns(data_string) - - handler.call(request, response) - - expect(response.body).to eq(data_string) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "should return a not_found_code when no model instance can be found" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_finds(data, :accept_header => "unknown, text/pson") - - handler.call(request, response) - expect(response.code).to eq(not_found_code) - end - end - - describe "when searching for model instances" do - it "uses the first supported format for the response" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_searches("my", :accept_header => "unknown, text/pson") - - handler.call(request, response) - - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - expect(response.body).to eq(Puppet::IndirectorTesting.render_multiple(:pson, [data])) - end - - it "should return [] when searching returns an empty array" do - request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") - - handler.call(request, response) - - expect(response.body).to eq("[]") - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "should return a not_found_code when searching returns nil" do - request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") - indirection.expects(:search).returns(nil) - - handler.call(request, response) - - expect(response.code).to eq(not_found_code) - end - end - - describe "when destroying a model instance" do - it "destroys the data indicated in the request" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_destroys(data) - - handler.call(request, response) - - Puppet::IndirectorTesting.indirection.find("my data").should be_nil - end - - it "responds with pson when no Accept header is given" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_destroys(data, :accept_header => nil) - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "uses the first supported format for the response" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_destroys(data, :accept_header => "unknown, text/pson") - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "raises an error and does not destroy when no accepted formats are known" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_destroys(data, :accept_header => "unknown, also/unknown") - - handler.call(request, response) - - expect(response.code).to eq(not_acceptable_code) - Puppet::IndirectorTesting.indirection.find("my data").should_not be_nil - end - end - - describe "when saving a model instance" do - it "allows an empty body when the format supports it" do - class Puppet::IndirectorTesting::Nonvalidatingmemory < Puppet::IndirectorTesting::Memory - def validate_key(_) - # nothing - end - end - - indirection.terminus_class = :nonvalidatingmemory - - data = Puppet::IndirectorTesting.new("test") - request = a_request_that_submits(data, - :content_type_header => "application/x-raw", - :body => '') - - handler.call(request, response) - - # PUP-3272 this test fails when yaml is removed and pson is used. Instead of returning an - # empty string, the a string '""' is returned - Don't know what the expecation is, if this is - # corrent or not. - # (helindbe) - # - Puppet::IndirectorTesting.indirection.find("test").name.should == '' - end - - it "saves the data sent in the request" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_submits(data) - - handler.call(request, response) - - saved = Puppet::IndirectorTesting.indirection.find("my data") - expect(saved.name).to eq(data.name) - end - - it "responds with pson when no Accept header is given" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_submits(data, :accept_header => nil) - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "uses the first supported format for the response" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_submits(data, :accept_header => "unknown, text/pson") - - handler.call(request, response) - - expect(response.body).to eq(data.render(:pson)) - expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) - end - - it "raises an error and does not save when no accepted formats are known" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_submits(data, :accept_header => "unknown, also/unknown") - - handler.call(request, response) - - expect(Puppet::IndirectorTesting.indirection.find("my data")).to be_nil - expect(response.code).to eq(not_acceptable_code) - end - end - - describe "when performing head operation" do - it "should not generate a response when a model head call succeeds" do - data = Puppet::IndirectorTesting.new("my data") - indirection.save(data, "my data") - request = a_request_that_heads(data) - - handler.call(request, response) - - expect(response.code).to eq(nil) - end - - it "should return a not_found_code when the model head call returns false" do - data = Puppet::IndirectorTesting.new("my data") - request = a_request_that_heads(data) - - handler.call(request, response) - - expect(response.code).to eq(not_found_code) - expect(response.type).to eq("text/plain") - expect(response.body).to eq("Not Found: Could not find indirector_testing my data") - end - end -end diff --git a/spec/unit/network/http/api/v3_spec.rb b/spec/unit/network/http/api/v3_spec.rb index 22ea63e54..b078e0206 100755 --- a/spec/unit/network/http/api/v3_spec.rb +++ b/spec/unit/network/http/api/v3_spec.rb @@ -1,486 +1,486 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http' require 'puppet/network/http/api/v3' require 'puppet/indirector_testing' describe Puppet::Network::HTTP::API::V3 do let(:not_found_code) { Puppet::Network::HTTP::Error::HTTPNotFoundError::CODE } let(:not_acceptable_code) { Puppet::Network::HTTP::Error::HTTPNotAcceptableError::CODE } let(:not_authorized_code) { Puppet::Network::HTTP::Error::HTTPNotAuthorizedError::CODE } let(:indirection) { Puppet::IndirectorTesting.indirection } let(:handler) { Puppet::Network::HTTP::API::V3.new } let(:response) { Puppet::Network::HTTP::MemoryResponse.new } let(:params) { { :environment => "production" } } def a_request_that_heads(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "HEAD", - :path => "/#{indirection.name}/#{data.value}", + :path => "/v3/#{indirection.name}/#{data.value}", :params => params, }) end def a_request_that_submits(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => request[:content_type_header] || "text/pson", }, :method => "PUT", - :path => "/#{indirection.name}/#{data.value}", + :path => "/v3/#{indirection.name}/#{data.value}", :params => params, :body => request[:body].nil? ? data.render("pson") : request[:body] }) end def a_request_that_destroys(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "DELETE", - :path => "/#{indirection.name}/#{data.value}", + :path => "/v3/#{indirection.name}/#{data.value}", :params => params, :body => '' }) end def a_request_that_finds(data, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "GET", - :path => "/#{indirection.name}/#{data.value}", + :path => "/v3/#{indirection.name}/#{data.value}", :params => params, :body => '' }) end def a_request_that_searches(key, request = {}) Puppet::Network::HTTP::Request.from_hash({ :headers => { 'accept' => request[:accept_header], 'content-type' => "text/pson", }, :method => "GET", - :path => "/#{indirection.name}s/#{key}", + :path => "/v3/#{indirection.name}s/#{key}", :params => params, :body => '' }) end before do Puppet::IndirectorTesting.indirection.terminus_class = :memory Puppet::IndirectorTesting.indirection.terminus.clear handler.stubs(:check_authorization) handler.stubs(:warn_if_near_expiration) end describe "when converting a URI into a request" do let(:environment) { Puppet::Node::Environment.create(:env, []) } let(:env_loaders) { Puppet::Environments::Static.new(environment) } let(:params) { { :environment => "env" } } before do handler.stubs(:handler).returns "foo" end around do |example| Puppet.override(:environments => env_loaders) do example.run end end it "should get the environment from a query parameter" do - handler.uri2indirection("GET", "/foo/bar", params)[3][:environment].to_s.should == "env" + handler.uri2indirection("GET", "/v3/foo/bar", params)[3][:environment].to_s.should == "env" end it "should fail if the environment is not alphanumeric" do - lambda { handler.uri2indirection("GET", "/foo/bar", {:environment => "env ness"}) }.should raise_error(ArgumentError) + lambda { handler.uri2indirection("GET", "/v3/foo/bar", {:environment => "env ness"}) }.should raise_error(ArgumentError) end it "should not pass a buck_path parameter through (See Bugs #13553, #13518, #13511)" do - handler.uri2indirection("GET", "/foo/bar", { :environment => "env", - :bucket_path => "/malicious/path" })[3].should_not include({ :bucket_path => "/malicious/path" }) + handler.uri2indirection("GET", "/v3/foo/bar", { :environment => "env", + :bucket_path => "/malicious/path" })[3].should_not include({ :bucket_path => "/malicious/path" }) end it "should pass allowed parameters through" do - handler.uri2indirection("GET", "/foo/bar", { :environment => "env", - :allowed_param => "value" })[3].should include({ :allowed_param => "value" }) + handler.uri2indirection("GET", "/v3/foo/bar", { :environment => "env", + :allowed_param => "value" })[3].should include({ :allowed_param => "value" }) end it "should return the environment as a Puppet::Node::Environment" do - handler.uri2indirection("GET", "/foo/bar", params)[3][:environment].should be_a(Puppet::Node::Environment) + handler.uri2indirection("GET", "/v3/foo/bar", params)[3][:environment].should be_a(Puppet::Node::Environment) end it "should use the first field of the URI as the indirection name" do - handler.uri2indirection("GET", "/foo/bar", params)[0].should == "foo" + handler.uri2indirection("GET", "/v3/foo/bar", params)[0].should == "foo" end it "should fail if the indirection name is not alphanumeric" do - lambda { handler.uri2indirection("GET", "/foo ness/bar", params) }.should raise_error(ArgumentError) + lambda { handler.uri2indirection("GET", "/v3/foo ness/bar", params) }.should raise_error(ArgumentError) end it "should use the remainder of the URI as the indirection key" do - handler.uri2indirection("GET", "/foo/bar", params)[2].should == "bar" + handler.uri2indirection("GET", "/v3/foo/bar", params)[2].should == "bar" end it "should support the indirection key being a /-separated file path" do - handler.uri2indirection("GET", "/foo/bee/baz/bomb", params)[2].should == "bee/baz/bomb" + handler.uri2indirection("GET", "/v3/foo/bee/baz/bomb", params)[2].should == "bee/baz/bomb" end it "should fail if no indirection key is specified" do - lambda { handler.uri2indirection("GET", "/foo", params) }.should raise_error(ArgumentError) + lambda { handler.uri2indirection("GET", "/v3/foo", params) }.should raise_error(ArgumentError) end it "should choose 'find' as the indirection method if the http method is a GET and the indirection name is singular" do - handler.uri2indirection("GET", "/foo/bar", params)[1].should == :find + handler.uri2indirection("GET", "/v3/foo/bar", params)[1].should == :find end it "should choose 'find' as the indirection method if the http method is a POST and the indirection name is singular" do - handler.uri2indirection("POST", "/foo/bar", params)[1].should == :find + handler.uri2indirection("POST", "/v3/foo/bar", params)[1].should == :find end it "should choose 'head' as the indirection method if the http method is a HEAD and the indirection name is singular" do - handler.uri2indirection("HEAD", "/foo/bar", params)[1].should == :head + handler.uri2indirection("HEAD", "/v3/foo/bar", params)[1].should == :head end it "should choose 'search' as the indirection method if the http method is a GET and the indirection name is plural" do - handler.uri2indirection("GET", "/foos/bar", params)[1].should == :search + handler.uri2indirection("GET", "/v3/foos/bar", params)[1].should == :search end it "should change indirection name to 'status' if the http method is a GET and the indirection name is statuses" do - handler.uri2indirection("GET", "/statuses/bar", params)[0].should == "status" + handler.uri2indirection("GET", "/v3/statuses/bar", params)[0].should == "status" end it "should change indirection name to 'node' if the http method is a GET and the indirection name is nodes" do - handler.uri2indirection("GET", "/nodes/bar", params)[0].should == "node" + handler.uri2indirection("GET", "/v3/nodes/bar", params)[0].should == "node" end it "should choose 'delete' as the indirection method if the http method is a DELETE and the indirection name is singular" do - handler.uri2indirection("DELETE", "/foo/bar", params)[1].should == :destroy + handler.uri2indirection("DELETE", "/v3/foo/bar", params)[1].should == :destroy end it "should choose 'save' as the indirection method if the http method is a PUT and the indirection name is singular" do - handler.uri2indirection("PUT", "/foo/bar", params)[1].should == :save + handler.uri2indirection("PUT", "/v3/foo/bar", params)[1].should == :save end it "should fail if an indirection method cannot be picked" do - lambda { handler.uri2indirection("UPDATE", "/node/bar", params) }.should raise_error(ArgumentError) + lambda { handler.uri2indirection("UPDATE", "/v3/node/bar", params) }.should raise_error(ArgumentError) end it "should URI unescape the indirection key" do escaped = URI.escape("foo bar") - indirection, method, key, final_params = handler.uri2indirection("GET", "/node/#{escaped}", params) + indirection, method, key, final_params = handler.uri2indirection("GET", "/v3/node/#{escaped}", params) key.should == "foo bar" end end describe "when converting a request into a URI" do let(:environment) { Puppet::Node::Environment.create(:myenv, []) } let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } before do handler.stubs(:handler).returns "foo" end it "should include the environment in the query string of the URI" do - handler.class.request_to_uri(request).should == "/foo/with%20spaces?environment=myenv&foo=bar" + handler.class.request_to_uri(request).should == "/v3/foo/with%20spaces?environment=myenv&foo=bar" end it "should pluralize the indirection name if the method is 'search'" do request.stubs(:method).returns :search - handler.class.request_to_uri(request).split("/")[1].should == "foos" + handler.class.request_to_uri(request).split("/")[2].should == "foos" end it "should add the query string to the URI" do request.expects(:query_string).returns "query" handler.class.request_to_uri(request).should =~ /\&query$/ end end describe "when converting a request into a URI with body" do let(:environment) { Puppet::Node::Environment.create(:myenv, []) } let(:request) { Puppet::Indirector::Request.new(:foo, :find, "with spaces", nil, :foo => :bar, :environment => environment) } it "should use the indirection as the first field of the URI" do - handler.class.request_to_uri_and_body(request).first.split("/")[1].should == "foo" + handler.class.request_to_uri_and_body(request).first.split("/")[2].should == "foo" end it "should use the escaped key as the remainder of the URI" do escaped = URI.escape("with spaces") - handler.class.request_to_uri_and_body(request).first.split("/")[2].sub(/\?.+/, '').should == escaped + handler.class.request_to_uri_and_body(request).first.split("/")[3].sub(/\?.+/, '').should == escaped end it "should return the URI and body separately" do - handler.class.request_to_uri_and_body(request).should == ["/foo/with%20spaces", "environment=myenv&foo=bar"] + handler.class.request_to_uri_and_body(request).should == ["/v3/foo/with%20spaces", "environment=myenv&foo=bar"] end end describe "when processing a request" do it "should return not_authorized_code if the request is not authorized" do request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) handler.expects(:check_authorization).raises(Puppet::Network::AuthorizationError.new("forbidden")) handler.call(request, response) expect(response.code).to eq(not_authorized_code) end it "should return 'not found' if the indirection does not support remote requests" do request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) indirection.expects(:allow_remote_requests?).returns(false) handler.call(request, response) expect(response.code).to eq(not_found_code) end it "should return 'not found' if the environment does not exist" do Puppet.override(:environments => Puppet::Environments::Static.new()) do request = a_request_that_heads(Puppet::IndirectorTesting.new("my data")) handler.call(request, response) expect(response.code).to eq(not_found_code) end end it "should serialize a controller exception when an exception is thrown while finding the model instance" do request = a_request_that_finds(Puppet::IndirectorTesting.new("key")) handler.expects(:do_find).raises(ArgumentError, "The exception") handler.call(request, response) expect(response.code).to eq(400) expect(response.body).to eq("The exception") expect(response.type).to eq("text/plain") end end describe "when finding a model instance" do it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_finds(data, :accept_header => "unknown, pson") handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "responds with a not_acceptable_code error when no accept header is provided" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_finds(data, :accept_header => nil) handler.call(request, response) expect(response.code).to eq(not_acceptable_code) end it "raises an error when no accepted formats are known" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_finds(data, :accept_header => "unknown, also/unknown") handler.call(request, response) expect(response.code).to eq(not_acceptable_code) end it "should pass the result through without rendering it if the result is a string" do data = Puppet::IndirectorTesting.new("my data") data_string = "my data string" request = a_request_that_finds(data, :accept_header => "text/pson") indirection.expects(:find).returns(data_string) handler.call(request, response) expect(response.body).to eq(data_string) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "should return a not_found_code when no model instance can be found" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_finds(data, :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.code).to eq(not_found_code) end end describe "when searching for model instances" do it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_searches("my", :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) expect(response.body).to eq(Puppet::IndirectorTesting.render_multiple(:pson, [data])) end it "should return [] when searching returns an empty array" do request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.body).to eq("[]") expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "should return a not_found_code when searching returns nil" do request = a_request_that_searches("nothing", :accept_header => "unknown, text/pson") indirection.expects(:search).returns(nil) handler.call(request, response) expect(response.code).to eq(not_found_code) end end describe "when destroying a model instance" do it "destroys the data indicated in the request" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data) handler.call(request, response) Puppet::IndirectorTesting.indirection.find("my data").should be_nil end it "responds with pson when no Accept header is given" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data, :accept_header => nil) handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data, :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "raises an error and does not destroy when no accepted formats are known" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_destroys(data, :accept_header => "unknown, also/unknown") handler.call(request, response) expect(response.code).to eq(not_acceptable_code) Puppet::IndirectorTesting.indirection.find("my data").should_not be_nil end end describe "when saving a model instance" do it "allows an empty body when the format supports it" do class Puppet::IndirectorTesting::Nonvalidatingmemory < Puppet::IndirectorTesting::Memory def validate_key(_) # nothing end end indirection.terminus_class = :nonvalidatingmemory data = Puppet::IndirectorTesting.new("test") request = a_request_that_submits(data, :content_type_header => "application/x-raw", :body => '') handler.call(request, response) # PUP-3272 this test fails when yaml is removed and pson is used. Instead of returning an # empty string, the a string '""' is returned - Don't know what the expecation is, if this is # corrent or not. # (helindbe) # Puppet::IndirectorTesting.indirection.find("test").name.should == '' end it "saves the data sent in the request" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data) handler.call(request, response) saved = Puppet::IndirectorTesting.indirection.find("my data") expect(saved.name).to eq(data.name) end it "responds with pson when no Accept header is given" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data, :accept_header => nil) handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "uses the first supported format for the response" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data, :accept_header => "unknown, text/pson") handler.call(request, response) expect(response.body).to eq(data.render(:pson)) expect(response.type).to eq(Puppet::Network::FormatHandler.format(:pson)) end it "raises an error and does not save when no accepted formats are known" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_submits(data, :accept_header => "unknown, also/unknown") handler.call(request, response) expect(Puppet::IndirectorTesting.indirection.find("my data")).to be_nil expect(response.code).to eq(not_acceptable_code) end end describe "when performing head operation" do it "should not generate a response when a model head call succeeds" do data = Puppet::IndirectorTesting.new("my data") indirection.save(data, "my data") request = a_request_that_heads(data) handler.call(request, response) expect(response.code).to eq(nil) end it "should return a not_found_code when the model head call returns false" do data = Puppet::IndirectorTesting.new("my data") request = a_request_that_heads(data) handler.call(request, response) expect(response.code).to eq(not_found_code) expect(response.type).to eq("text/plain") expect(response.body).to eq("Not Found: Could not find indirector_testing my data") end end end diff --git a/spec/unit/type/file/content_spec.rb b/spec/unit/type/file/content_spec.rb index a0eba81c5..a9d73a494 100755 --- a/spec/unit/type/file/content_spec.rb +++ b/spec/unit/type/file/content_spec.rb @@ -1,521 +1,521 @@ #! /usr/bin/env ruby require 'spec_helper' require 'puppet/network/http_pool' require 'puppet/network/resolver' describe Puppet::Type.type(:file).attrclass(:content), :uses_checksums => true do include PuppetSpec::Files let(:filename) { tmpfile('testfile') } let(:environment) { Puppet::Node::Environment.create(:testing, []) } let(:catalog) { Puppet::Resource::Catalog.new(:test, environment) } let(:resource) { Puppet::Type.type(:file).new :path => filename, :catalog => catalog } before do File.open(filename, 'w') {|f| f.write "initial file content"} described_class.stubs(:standalone?).returns(false) end around do |example| Puppet.override(:environments => Puppet::Environments::Static.new(environment)) do example.run end end describe "when determining the checksum type" do let(:content) { described_class.new(:resource => resource) } it "should use the type specified in the source checksum if a source is set" do resource[:source] = File.expand_path("/foo") resource.parameter(:source).expects(:checksum).returns "{md5lite}eh" content.checksum_type.should == :md5lite end it "should use the type specified by the checksum parameter if no source is set" do resource[:checksum] = :md5lite content.checksum_type.should == :md5lite end with_digest_algorithms do it "should use the type specified by digest_algorithm by default" do content.checksum_type.should == digest_algorithm.intern end end end describe "when determining the actual content to write" do let(:content) { described_class.new(:resource => resource) } it "should use the set content if available" do content.should = "ehness" content.actual_content.should == "ehness" end it "should not use the content from the source if the source is set" do source = mock 'source' resource.expects(:parameter).never.with(:source).returns source content.actual_content.should be_nil end end describe "when setting the desired content" do let(:content) { described_class.new(:resource => resource) } it "should make the actual content available via an attribute" do content.stubs(:checksum_type).returns "md5" content.should = "this is some content" content.actual_content.should == "this is some content" end with_digest_algorithms do it "should store the checksum as the desired content" do d = digest("this is some content") content.stubs(:checksum_type).returns digest_algorithm content.should = "this is some content" content.should.must == "{#{digest_algorithm}}#{d}" end it "should not checksum 'absent'" do content.should = :absent content.should.must == :absent end it "should accept a checksum as the desired content" do d = digest("this is some content") string = "{#{digest_algorithm}}#{d}" content.should = string content.should.must == string end end it "should convert the value to ASCII-8BIT", :if => "".respond_to?(:encode) do content.should= "Let's make a \u{2603}" content.actual_content.should == "Let's make a \xE2\x98\x83".force_encoding(Encoding::ASCII_8BIT) end end describe "when retrieving the current content" do let(:content) { described_class.new(:resource => resource) } it "should return :absent if the file does not exist" do resource.expects(:stat).returns nil content.retrieve.should == :absent end it "should not manage content on directories" do stat = mock 'stat', :ftype => "directory" resource.expects(:stat).returns stat content.retrieve.should be_nil end it "should not manage content on links" do stat = mock 'stat', :ftype => "link" resource.expects(:stat).returns stat content.retrieve.should be_nil end it "should always return the checksum as a string" do resource[:checksum] = :mtime stat = mock 'stat', :ftype => "file" resource.expects(:stat).returns stat time = Time.now resource.parameter(:checksum).expects(:mtime_file).with(resource[:path]).returns time content.retrieve.should == "{mtime}#{time}" end with_digest_algorithms do it "should return the checksum of the file if it exists and is a normal file" do stat = mock 'stat', :ftype => "file" resource.expects(:stat).returns stat resource.parameter(:checksum).expects("#{digest_algorithm}_file".intern).with(resource[:path]).returns "mysum" content.retrieve.should == "{#{digest_algorithm}}mysum" end end end describe "when testing whether the content is in sync" do let(:content) { described_class.new(:resource => resource) } before do resource[:ensure] = :file end it "should return true if the resource shouldn't be a regular file" do resource.expects(:should_be_file?).returns false content.should = "foo" content.must be_safe_insync("whatever") end it "should warn that no content will be synced to links when ensure is :present" do resource[:ensure] = :present resource[:content] = 'foo' resource.stubs(:should_be_file?).returns false resource.stubs(:stat).returns mock("stat", :ftype => "link") resource.expects(:warning).with {|msg| msg =~ /Ensure set to :present but file type is/} content.insync? :present end it "should return false if the current content is :absent" do content.should = "foo" content.should_not be_safe_insync(:absent) end it "should return false if the file should be a file but is not present" do resource.expects(:should_be_file?).returns true content.should = "foo" content.should_not be_safe_insync(:absent) end describe "and the file exists" do with_digest_algorithms do before do resource.stubs(:stat).returns mock("stat") resource[:checksum] = digest_algorithm content.should = "some content" end it "should return false if the current contents are different from the desired content" do content.should_not be_safe_insync("other content") end it "should return true if the sum for the current contents is the same as the sum for the desired content" do content.must be_safe_insync("{#{digest_algorithm}}" + digest("some content")) end [true, false].product([true, false]).each do |cfg, param| describe "and Puppet[:show_diff] is #{cfg} and show_diff => #{param}" do before do Puppet[:show_diff] = cfg resource.stubs(:show_diff?).returns param resource[:loglevel] = "debug" end if cfg and param it "should display a diff" do content.expects(:diff).returns("my diff").once content.expects(:debug).with("\nmy diff").once content.should_not be_safe_insync("other content") end else it "should not display a diff" do content.expects(:diff).never content.should_not be_safe_insync("other content") end end end end end end describe "and :replace is false" do before do resource.stubs(:replace?).returns false end it "should be insync if the file exists and the content is different" do resource.stubs(:stat).returns mock('stat') content.must be_safe_insync("whatever") end it "should be insync if the file exists and the content is right" do resource.stubs(:stat).returns mock('stat') content.must be_safe_insync("something") end it "should not be insync if the file does not exist" do content.should = "foo" content.should_not be_safe_insync(:absent) end end end describe "when changing the content" do let(:content) { described_class.new(:resource => resource) } before do resource.stubs(:[]).with(:path).returns "/boo" resource.stubs(:stat).returns "eh" end it "should use the file's :write method to write the content" do resource.expects(:write).with(:content) content.sync end it "should return :file_changed if the file already existed" do resource.expects(:stat).returns "something" resource.stubs(:write) content.sync.should == :file_changed end it "should return :file_created if the file did not exist" do resource.expects(:stat).returns nil resource.stubs(:write) content.sync.should == :file_created end end describe "when writing" do let(:content) { described_class.new(:resource => resource) } let(:fh) { File.open(filename, 'wb') } it "should attempt to read from the filebucket if no actual content nor source exists" do content.should = "{md5}foo" content.resource.bucket.class.any_instance.stubs(:getfile).returns "foo" content.write(fh) fh.close end describe "from actual content" do before(:each) do content.stubs(:actual_content).returns("this is content") end it "should write to the given file handle" do fh = mock 'filehandle' fh.expects(:print).with("this is content") content.write(fh) end it "should return the current checksum value" do resource.parameter(:checksum).expects(:sum_stream).returns "checksum" content.write(fh).should == "checksum" end end describe "from a file bucket" do it "should fail if a file bucket cannot be retrieved" do content.should = "{md5}foo" content.resource.expects(:bucket).returns nil expect { content.write(fh) }.to raise_error(Puppet::Error) end it "should fail if the file bucket cannot find any content" do content.should = "{md5}foo" bucket = stub 'bucket' content.resource.expects(:bucket).returns bucket bucket.expects(:getfile).with("foo").raises "foobar" expect { content.write(fh) }.to raise_error(Puppet::Error) end it "should write the returned content to the file" do content.should = "{md5}foo" bucket = stub 'bucket' content.resource.expects(:bucket).returns bucket bucket.expects(:getfile).with("foo").returns "mycontent" fh = mock 'filehandle' fh.expects(:print).with("mycontent") content.write(fh) end end describe "from local source" do let(:source_content) { "source file content\r\n"*10 } before(:each) do sourcename = tmpfile('source') resource[:backup] = false resource[:source] = sourcename File.open(sourcename, 'wb') {|f| f.write source_content} # This needs to be invoked to properly initialize the content property, # or attempting to write a file will fail. resource.newattr(:content) end it "should copy content from the source to the file" do source = resource.parameter(:source) resource.write(source) Puppet::FileSystem.binread(filename).should == source_content end with_digest_algorithms do it "should return the checksum computed" do File.open(filename, 'wb') do |file| resource[:checksum] = digest_algorithm content.write(file).should == "{#{digest_algorithm}}#{digest(source_content)}" end end end end describe 'from remote source' do let(:source_content) { "source file content\n"*10 } let(:source) { resource.newattr(:source) } let(:response) { stub_everything('response') } let(:conn) { mock('connection') } before(:each) do resource[:backup] = false # This needs to be invoked to properly initialize the content property, # or attempting to write a file will fail. resource.newattr(:content) response.stubs(:read_body).multiple_yields(*source_content.lines) conn.stubs(:request_get).yields(response) end it 'should use an explicit fileserver if source starts with puppet://' do response.stubs(:code).returns('200') source.stubs(:metadata).returns stub_everything('metadata', :source => 'puppet://somehostname/test/foo', :ftype => 'file') Puppet::Network::HttpPool.expects(:http_instance).with('somehostname', anything).returns(conn) resource.write(source) end it 'should use the default fileserver if source starts with puppet:///' do response.stubs(:code).returns('200') source.stubs(:metadata).returns stub_everything('metadata', :source => 'puppet:///test/foo', :ftype => 'file') Puppet::Network::HttpPool.expects(:http_instance).with(Puppet.settings[:server], anything).returns(conn) resource.write(source) end it 'should percent encode reserved characters' do response.stubs(:code).returns('200') Puppet::Network::HttpPool.stubs(:http_instance).returns(conn) source.stubs(:metadata).returns stub_everything('metadata', :source => 'puppet:///test/foo bar', :ftype => 'file') conn.unstub(:request_get) - conn.expects(:request_get).with('/file_content/test/foo%20bar?environment=testing&', anything).yields(response) + conn.expects(:request_get).with('/v3/file_content/test/foo%20bar?environment=testing&', anything).yields(response) resource.write(source) end describe 'when handling file_content responses' do before(:each) do Puppet::Network::HttpPool.stubs(:http_instance).returns(conn) source.stubs(:metadata).returns stub_everything('metadata', :source => 'puppet:///test/foo', :ftype => 'file') end it 'should not write anything if source is not found' do response.stubs(:code).returns('404') expect { resource.write(source) }.to raise_error(Net::HTTPError, /404/) expect(File.read(filename)).to eq('initial file content') end it 'should raise an HTTP error in case of server error' do response.stubs(:code).returns('500') expect { resource.write(source) }.to raise_error(Net::HTTPError, /500/) end context 'and the request was successful' do before(:each) { response.stubs(:code).returns '200' } it 'should write the contents to the file' do resource.write(source) expect(Puppet::FileSystem.binread(filename)).to eq(source_content) end with_digest_algorithms do it 'should return the checksum computed' do File.open(filename, 'w') do |file| resource[:checksum] = digest_algorithm expect(content.write(file)).to eq("{#{digest_algorithm}}#{digest(source_content)}") end end end end end end # These are testing the implementation rather than the desired behaviour; while that bites, there are a whole # pile of other methods in the File type that depend on intimate details of this implementation and vice-versa. # If these blow up, you are gonna have to review the callers to make sure they don't explode! --daniel 2011-02-01 describe "each_chunk_from should work" do it "when content is a string" do content.each_chunk_from('i_am_a_string') { |chunk| chunk.should == 'i_am_a_string' } end # The following manifest is a case where source and content.should are both set # file { "/tmp/mydir" : # source => '/tmp/sourcedir', # recurse => true, # } it "when content checksum comes from source" do source_param = Puppet::Type.type(:file).attrclass(:source) source = source_param.new(:resource => resource) content.should = "{md5}123abcd" content.expects(:chunk_file_from_source).returns('from_source') content.each_chunk_from(source) { |chunk| chunk.should == 'from_source' } end it "when no content, source, but ensure present" do resource[:ensure] = :present content.each_chunk_from(nil) { |chunk| chunk.should == '' } end # you might do this if you were just auditing it "when no content, source, but ensure file" do resource[:ensure] = :file content.each_chunk_from(nil) { |chunk| chunk.should == '' } end it "when source_or_content is nil and content not a checksum" do content.each_chunk_from(nil) { |chunk| chunk.should == '' } end # the content is munged so that if it's a checksum nil gets passed in it "when content is a checksum it should try to read from filebucket" do content.should = "{md5}123abcd" content.expects(:read_file_from_filebucket).once.returns('im_a_filebucket') content.each_chunk_from(nil) { |chunk| chunk.should == 'im_a_filebucket' } end it "when running as puppet apply" do Puppet[:default_file_terminus] = "file_server" source_or_content = stubs('source_or_content') source_or_content.expects(:content).once.returns :whoo content.each_chunk_from(source_or_content) { |chunk| chunk.should == :whoo } end it "when running from source with a local file" do source_or_content = stubs('source_or_content') source_or_content.expects(:local?).returns true content.expects(:chunk_file_from_disk).with(source_or_content).once.yields 'woot' content.each_chunk_from(source_or_content) { |chunk| chunk.should == 'woot' } end it "when running from source with a remote file" do source_or_content = stubs('source_or_content') source_or_content.expects(:local?).returns false content.expects(:chunk_file_from_source).with(source_or_content).once.yields 'woot' content.each_chunk_from(source_or_content) { |chunk| chunk.should == 'woot' } end end end end