summaryrefslogtreecommitdiff
path: root/lib/chef/rest.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/chef/rest.rb')
-rw-r--r--lib/chef/rest.rb526
1 files changed, 526 insertions, 0 deletions
diff --git a/lib/chef/rest.rb b/lib/chef/rest.rb
new file mode 100644
index 0000000000..1e67d762b3
--- /dev/null
+++ b/lib/chef/rest.rb
@@ -0,0 +1,526 @@
+#--
+# Author:: Adam Jacob (<adam@opscode.com>)
+# Author:: Thom May (<thom@clearairturbulence.org>)
+# Author:: Nuo Yan (<nuo@opscode.com>)
+# Author:: Christopher Brown (<cb@opscode.com>)
+# Author:: Christopher Walters (<cw@opscode.com>)
+# Copyright:: Copyright (c) 2009, 2010 Opscode, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'zlib'
+require 'net/https'
+require 'uri'
+require 'chef/json_compat'
+require 'tempfile'
+require 'chef/rest/auth_credentials'
+require 'chef/rest/rest_request'
+require 'chef/monkey_patches/string'
+require 'chef/monkey_patches/net_http'
+require 'chef/config'
+require 'chef/exceptions'
+
+
+
+class Chef
+ # == Chef::REST
+ # Chef's custom REST client with built-in JSON support and RSA signed header
+ # authentication.
+ class REST
+
+ class NoopInflater
+ def inflate(chunk)
+ chunk
+ end
+ end
+
+ attr_reader :auth_credentials
+ attr_accessor :url, :cookies, :sign_on_redirect, :redirect_limit
+
+ CONTENT_ENCODING = "content-encoding".freeze
+ GZIP = "gzip".freeze
+ DEFLATE = "deflate".freeze
+ IDENTITY = "identity".freeze
+
+ # Create a REST client object. The supplied +url+ is used as the base for
+ # all subsequent requests. For example, when initialized with a base url
+ # http://localhost:4000, a call to +get_rest+ with 'nodes' will make an
+ # HTTP GET request to http://localhost:4000/nodes
+ def initialize(url, client_name=Chef::Config[:node_name], signing_key_filename=Chef::Config[:client_key], options={})
+ @url = url
+ @cookies = CookieJar.instance
+ @default_headers = options[:headers] || {}
+ @signing_key_filename = signing_key_filename
+ @key = load_signing_key(@signing_key_filename, options[:raw_key])
+ @auth_credentials = AuthCredentials.new(client_name, @key)
+ @sign_on_redirect, @sign_request = true, true
+ @redirects_followed = 0
+ @redirect_limit = 10
+ @disable_gzip = false
+ handle_options(options)
+ end
+
+ def signing_key_filename
+ @signing_key_filename
+ end
+
+ def client_name
+ @auth_credentials.client_name
+ end
+
+ def signing_key
+ @raw_key
+ end
+
+ # Register the client
+ #--
+ # Requires you to load chef/api_client beforehand. explicit require is removed since
+ # most users of this class have no need for chef/api_client. This functionality
+ # should be moved anyway...
+ def register(name=Chef::Config[:node_name], destination=Chef::Config[:client_key])
+ if (File.exists?(destination) && !File.writable?(destination))
+ raise Chef::Exceptions::CannotWritePrivateKey, "I cannot write your private key to #{destination} - check permissions?"
+ end
+ nc = Chef::ApiClient.new
+ nc.name(name)
+
+ catch(:done) do
+ retries = config[:client_registration_retries] || 5
+ 0.upto(retries) do |n|
+ begin
+ response = nc.save(true, true)
+ Chef::Log.debug("Registration response: #{response.inspect}")
+ raise Chef::Exceptions::CannotWritePrivateKey, "The response from the server did not include a private key!" unless response.has_key?("private_key")
+ # Write out the private key
+ ::File.open(destination, "w") {|f|
+ f.chmod(0600)
+ f.print(response["private_key"])
+ }
+ throw :done
+ rescue IOError
+ raise Chef::Exceptions::CannotWritePrivateKey, "I cannot write your private key to #{destination}"
+ rescue Net::HTTPFatalError => e
+ Chef::Log.warn("Failed attempt #{n} of #{retries+1} on client creation")
+ raise unless e.response.code == "500"
+ end
+ end
+ end
+
+ true
+ end
+
+ # Send an HTTP GET request to the path
+ #
+ # Using this method to +fetch+ a file is considered deprecated.
+ #
+ # === Parameters
+ # path:: The path to GET
+ # raw:: Whether you want the raw body returned, or JSON inflated. Defaults
+ # to JSON inflated.
+ def get_rest(path, raw=false, headers={})
+ if raw
+ streaming_request(create_url(path), headers)
+ else
+ api_request(:GET, create_url(path), headers)
+ end
+ end
+
+ # Send an HTTP DELETE request to the path
+ def delete_rest(path, headers={})
+ api_request(:DELETE, create_url(path), headers)
+ end
+
+ # Send an HTTP POST request to the path
+ def post_rest(path, json, headers={})
+ api_request(:POST, create_url(path), headers, json)
+ end
+
+ # Send an HTTP PUT request to the path
+ def put_rest(path, json, headers={})
+ api_request(:PUT, create_url(path), headers, json)
+ end
+
+ # Streams a download to a tempfile, then yields the tempfile to a block.
+ # After the download, the tempfile will be closed and unlinked.
+ # If you rename the tempfile, it will not be deleted.
+ # Beware that if the server streams infinite content, this method will
+ # stream it until you run out of disk space.
+ def fetch(path, headers={})
+ streaming_request(create_url(path), headers) {|tmp_file| yield tmp_file }
+ end
+
+ def create_url(path)
+ if path =~ /^(http|https):\/\//
+ URI.parse(path)
+ else
+ URI.parse("#{@url}/#{path}")
+ end
+ end
+
+ def sign_requests?
+ auth_credentials.sign_requests? && @sign_request
+ end
+
+ # ==== DEPRECATED
+ # Use +api_request+ instead
+ #--
+ # Actually run an HTTP request. First argument is the HTTP method,
+ # which should be one of :GET, :PUT, :POST or :DELETE. Next is the
+ # URL, then an object to include in the body (which will be converted with
+ # .to_json). The limit argument is unused, it is present for backwards
+ # compatibility. Configure the redirect limit with #redirect_limit=
+ # instead.
+ #
+ # Typically, you won't use this method -- instead, you'll use one of
+ # the helper methods (get_rest, post_rest, etc.)
+ #
+ # Will return the body of the response on success.
+ def run_request(method, url, headers={}, data=false, limit=nil, raw=false)
+ json_body = data ? Chef::JSONCompat.to_json(data) : nil
+ # Force encoding to binary to fix SSL related EOFErrors
+ # cf. http://tickets.opscode.com/browse/CHEF-2363
+ # http://redmine.ruby-lang.org/issues/5233
+ json_body.force_encoding(Encoding::BINARY) if json_body.respond_to?(:force_encoding)
+ headers = build_headers(method, url, headers, json_body, raw)
+
+ tf, response_body = nil, nil
+
+ retriable_rest_request(method, url, json_body, headers) do |rest_request|
+
+ res = rest_request.call do |response|
+ if raw
+ tf = stream_to_tempfile(url, response)
+ else
+ response_body = decompress_body(response)
+ end
+ end
+
+ case res
+ when Net::HTTPSuccess
+ if res['content-type'] =~ /json/
+ Chef::JSONCompat.from_json(response_body)
+ else
+ if method == :HEAD
+ true
+ elsif raw
+ tf
+ else
+ response_body
+ end
+ end
+ when Net::HTTPNotModified # Must be tested before Net::HTTPRedirection because it's subclass.
+ false
+ when Net::HTTPRedirection
+ follow_redirect {run_request(method, create_url(res['location']), headers, false, nil, raw)}
+ else
+ if res['content-type'] =~ /json/
+ exception = Chef::JSONCompat.from_json(response_body)
+ msg = "HTTP Request Returned #{res.code} #{res.message}: "
+ msg << (exception["error"].respond_to?(:join) ? exception["error"].join(", ") : exception["error"].to_s)
+ Chef::Log.warn(msg)
+ end
+ res.error!
+ end
+ end
+ end
+
+ # Runs an HTTP request to a JSON API with JSON body. File Download not supported.
+ def api_request(method, url, headers={}, data=false)
+ json_body = data ? Chef::JSONCompat.to_json(data) : nil
+ # Force encoding to binary to fix SSL related EOFErrors
+ # cf. http://tickets.opscode.com/browse/CHEF-2363
+ # http://redmine.ruby-lang.org/issues/5233
+ json_body.force_encoding(Encoding::BINARY) if json_body.respond_to?(:force_encoding)
+ raw_http_request(method, url, headers, json_body)
+ end
+
+ # Runs an HTTP request to a JSON API with raw body. File Download not supported.
+ def raw_http_request(method, url, headers, body)
+ headers = build_headers(method, url, headers, body)
+ retriable_rest_request(method, url, body, headers) do |rest_request|
+ begin
+ response = rest_request.call {|r| r.read_body}
+
+ Chef::Log.debug("---- HTTP Status and Header Data: ----")
+ Chef::Log.debug("HTTP #{response.http_version} #{response.code} #{response.msg}")
+
+ response.each do |header, value|
+ Chef::Log.debug("#{header}: #{value}")
+ end
+ Chef::Log.debug("---- End HTTP Status/Header Data ----")
+
+ response_body = decompress_body(response)
+
+ if response.kind_of?(Net::HTTPSuccess)
+ if response['content-type'] =~ /json/
+ Chef::JSONCompat.from_json(response_body.chomp)
+ else
+ Chef::Log.warn("Expected JSON response, but got content-type '#{response['content-type']}'")
+ response_body
+ end
+ elsif redirect_location = redirected_to(response)
+ follow_redirect {api_request(:GET, create_url(redirect_location))}
+ else
+ # have to decompress the body before making an exception for it. But the body could be nil.
+ response.body.replace(decompress_body(response)) if response.body.respond_to?(:replace)
+
+ if response['content-type'] =~ /json/
+ exception = Chef::JSONCompat.from_json(response_body)
+ msg = "HTTP Request Returned #{response.code} #{response.message}: "
+ msg << (exception["error"].respond_to?(:join) ? exception["error"].join(", ") : exception["error"].to_s)
+ Chef::Log.info(msg)
+ end
+ response.error!
+ end
+ rescue Exception => e
+ if e.respond_to?(:chef_rest_request=)
+ e.chef_rest_request = rest_request
+ end
+ raise
+ end
+ end
+ end
+
+ def decompress_body(response)
+ if gzip_disabled?
+ response.body
+ else
+ case response[CONTENT_ENCODING]
+ when GZIP
+ Chef::Log.debug "decompressing gzip response"
+ Zlib::Inflate.new(Zlib::MAX_WBITS + 16).inflate(response.body)
+ when DEFLATE
+ Chef::Log.debug "decompressing deflate response"
+ Zlib::Inflate.inflate(response.body)
+ else
+ response.body
+ end
+ end
+ end
+
+ # Makes a streaming download request. <b>Doesn't speak JSON.</b>
+ # Streams the response body to a tempfile. If a block is given, it's
+ # passed to Tempfile.open(), which means that the tempfile will automatically
+ # be unlinked after the block is executed.
+ #
+ # If no block is given, the tempfile is returned, which means it's up to
+ # you to unlink the tempfile when you're done with it.
+ def streaming_request(url, headers, &block)
+ headers = build_headers(:GET, url, headers, nil, true)
+ retriable_rest_request(:GET, url, nil, headers) do |rest_request|
+ begin
+ tempfile = nil
+ response = rest_request.call do |r|
+ if block_given? && r.kind_of?(Net::HTTPSuccess)
+ begin
+ tempfile = stream_to_tempfile(url, r, &block)
+ yield tempfile
+ ensure
+ tempfile.close!
+ end
+ else
+ tempfile = stream_to_tempfile(url, r)
+ end
+ end
+ if response.kind_of?(Net::HTTPSuccess)
+ tempfile
+ elsif redirect_location = redirected_to(response)
+ # TODO: test tempfile unlinked when following redirects.
+ tempfile && tempfile.close!
+ follow_redirect {streaming_request(create_url(redirect_location), {}, &block)}
+ else
+ tempfile && tempfile.close!
+ response.error!
+ end
+ rescue Exception => e
+ if e.respond_to?(:chef_rest_request=)
+ e.chef_rest_request = rest_request
+ end
+ raise
+ end
+ end
+ end
+
+ def retriable_rest_request(method, url, req_body, headers)
+ rest_request = Chef::REST::RESTRequest.new(method, url, req_body, headers)
+
+ Chef::Log.debug("Sending HTTP Request via #{method} to #{url.host}:#{url.port}#{rest_request.path}")
+
+ http_attempts = 0
+
+ begin
+ http_attempts += 1
+
+ res = yield rest_request
+
+ rescue SocketError, Errno::ETIMEDOUT => e
+ e.message.replace "Error connecting to #{url} - #{e.message}"
+ raise e
+ rescue Errno::ECONNREFUSED
+ if http_retry_count - http_attempts + 1 > 0
+ Chef::Log.error("Connection refused connecting to #{url.host}:#{url.port} for #{rest_request.path}, retry #{http_attempts}/#{http_retry_count}")
+ sleep(http_retry_delay)
+ retry
+ end
+ raise Errno::ECONNREFUSED, "Connection refused connecting to #{url.host}:#{url.port} for #{rest_request.path}, giving up"
+ rescue Timeout::Error
+ if http_retry_count - http_attempts + 1 > 0
+ Chef::Log.error("Timeout connecting to #{url.host}:#{url.port} for #{rest_request.path}, retry #{http_attempts}/#{http_retry_count}")
+ sleep(http_retry_delay)
+ retry
+ end
+ raise Timeout::Error, "Timeout connecting to #{url.host}:#{url.port} for #{rest_request.path}, giving up"
+ rescue Net::HTTPFatalError => e
+ if http_retry_count - http_attempts + 1 > 0
+ sleep_time = 1 + (2 ** http_attempts) + rand(2 ** http_attempts)
+ Chef::Log.error("Server returned error for #{url}, retrying #{http_attempts}/#{http_retry_count} in #{sleep_time}s")
+ sleep(sleep_time)
+ retry
+ end
+ raise
+ end
+ end
+
+ def authentication_headers(method, url, json_body=nil)
+ request_params = {:http_method => method, :path => url.path, :body => json_body, :host => "#{url.host}:#{url.port}"}
+ request_params[:body] ||= ""
+ auth_credentials.signature_headers(request_params)
+ end
+
+ def http_retry_delay
+ config[:http_retry_delay]
+ end
+
+ def http_retry_count
+ config[:http_retry_count]
+ end
+
+ def config
+ Chef::Config
+ end
+
+ def follow_redirect
+ raise Chef::Exceptions::RedirectLimitExceeded if @redirects_followed >= redirect_limit
+ @redirects_followed += 1
+ Chef::Log.debug("Following redirect #{@redirects_followed}/#{redirect_limit}")
+ if @sign_on_redirect
+ yield
+ else
+ @sign_request = false
+ yield
+ end
+ ensure
+ @redirects_followed = 0
+ @sign_request = true
+ end
+
+ private
+
+ def redirected_to(response)
+ return nil unless response.kind_of?(Net::HTTPRedirection)
+ # Net::HTTPNotModified is undesired subclass of Net::HTTPRedirection so test for this
+ return nil if response.kind_of?(Net::HTTPNotModified)
+ response['location']
+ end
+
+ def build_headers(method, url, headers={}, json_body=false, raw=false)
+ headers = @default_headers.merge(headers)
+ #headers['Accept'] = "application/json" unless raw
+ headers['Accept'] = "application/json" unless raw
+ headers["Content-Type"] = 'application/json' if json_body
+ headers['Content-Length'] = json_body.bytesize.to_s if json_body
+ headers[RESTRequest::ACCEPT_ENCODING] = RESTRequest::ENCODING_GZIP_DEFLATE unless gzip_disabled?
+ headers.merge!(authentication_headers(method, url, json_body)) if sign_requests?
+ headers.merge!(Chef::Config[:custom_http_headers]) if Chef::Config[:custom_http_headers]
+ headers
+ end
+
+ def stream_to_tempfile(url, response)
+ tf = Tempfile.open("chef-rest")
+ if Chef::Platform.windows?
+ tf.binmode #required for binary files on Windows platforms
+ end
+ Chef::Log.debug("Streaming download from #{url.to_s} to tempfile #{tf.path}")
+ # Stolen from http://www.ruby-forum.com/topic/166423
+ # Kudos to _why!
+ size, total = 0, response.header['Content-Length'].to_i
+
+ inflater = if gzip_disabled?
+ NoopInflater.new
+ else
+ case response[CONTENT_ENCODING]
+ when GZIP
+ Chef::Log.debug "decompressing gzip stream"
+ Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
+ when DEFLATE
+ Chef::Log.debug "decompressing inflate stream"
+ Zlib::Inflate.new
+ else
+ NoopInflater.new
+ end
+ end
+
+ response.read_body do |chunk|
+ tf.write(inflater.inflate(chunk))
+ size += chunk.size
+ end
+ tf.close
+ tf
+ rescue Exception
+ tf.close!
+ raise
+ end
+
+ # gzip is disabled using the disable_gzip => true option in the
+ # constructor. When gzip is disabled, no 'Accept-Encoding' header will be
+ # set, and the response will not be decompressed, no matter what the
+ # Content-Encoding header of the response is. The intended use case for
+ # this is to work around situations where you request +file.tar.gz+, but
+ # the server responds with a content type of tar and a content encoding of
+ # gzip, tricking the client into decompressing the response so you end up
+ # with a tar archive (no gzip) named file.tar.gz
+ def gzip_disabled?
+ @disable_gzip
+ end
+
+ def handle_options(opts)
+ opts.each do |name, value|
+ case name.to_s
+ when 'disable_gzip'
+ @disable_gzip = value
+ end
+ end
+ end
+
+ def load_signing_key(key_file, raw_key = nil)
+ if (!!key_file)
+ @raw_key = IO.read(key_file).strip
+ elsif (!!raw_key)
+ @raw_key = raw_key.strip
+ else
+ return nil
+ end
+ @key = OpenSSL::PKey::RSA.new(@raw_key)
+ rescue SystemCallError, IOError => e
+ Chef::Log.warn "Failed to read the private key #{key_file}: #{e.inspect}"
+ raise Chef::Exceptions::PrivateKeyMissing, "I cannot read #{key_file}, which you told me to use to sign requests!"
+ rescue OpenSSL::PKey::RSAError
+ msg = "The file #{key_file} or :raw_key option does not contain a correctly formatted private key.\n"
+ msg << "The key file should begin with '-----BEGIN RSA PRIVATE KEY-----' and end with '-----END RSA PRIVATE KEY-----'"
+ raise Chef::Exceptions::InvalidPrivateKey, msg
+ end
+
+ end
+end