#
# Copyright (c) 2015-2017 Red Hat, Inc.
#
# 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 'json'
require 'tempfile'
require 'uri'
module OvirtSDK4
#
# This class is responsible for managing an HTTP connection to the engine server. It is intended as the entry
# point for the SDK, and it provides access to the `system` service and, from there, to the rest of the services
# provided by the API.
#
class Connection
#
# Creates a new connection to the API server.
#
# [source,ruby]
# ----
# connection = OvirtSDK4::Connection.new(
# url: 'https://engine.example.com/ovirt-engine/api',
# username: 'admin@internal',
# password: '...',
# ca_file:'/etc/pki/ovirt-engine/ca.pem'
# )
# ----
#
# @param opts [Hash] The options used to create the connection.
#
# @option opts [String] :url A string containing the base URL of the server, usually something like
# `\https://server.example.com/ovirt-engine/api`.
#
# @option opts [String] :username The name of the user, something like `admin@internal`.
#
# @option opts [String] :password The password of the user.
#
# @option opts [String] :token The token used to authenticate. Optionally the caller can explicitly provide
# the token, instead of the user name and password. If the token isn't provided then it will be automatically
# created.
#
# @option opts [Boolean] :insecure (false) A boolean flag that indicates if the server TLS certificate and host
# name should be checked.
#
# @option opts [String] :ca_file The name of a PEM file containing the trusted CA certificates. The certificate
# presented by the server will be verified using these CA certificates. If neither this nor the `ca_certs`
# options are provided, then the system wide CA certificates store is used. If both options are provided,
# then the certificates from both options will be trusted.
#
# @option opts [Array<String>] :ca_certs An array of strings containing the trusted CA certificates, in PEM
# format. The certificate presented by the server will be verified using these CA certificates. If neither this
# nor the `ca_file` options are provided, then the system wide CA certificates store is used. If both options
# are provided, then the certificates from both options will be trusted.
#
# @option opts [Boolean] :debug (false) A boolean flag indicating if debug output should be generated. If the
# values is `true` and the `log` parameter isn't `nil` then the data sent to and received from the server will be
# written to the log. Be aware that user names and passwords will also be written, so handle with care.
#
# @option opts [Logger] :log The logger where the log messages will be written.
#
# @option opts [Boolean] :kerberos (false) A boolean flag indicating if Kerberos authentication should be used
# instead of user name and password to obtain the OAuth token.
#
# @option opts [Integer] :timeout (0) The maximun total time to wait for the response, in seconds. A value of zero
# (the default) means wait for ever. If the timeout expires before the response is received a `TimeoutError`
# exception will be raised.
#
# @option opts [Integer] :connect_timeout (300) The maximun time to wait for connection establishment, in seconds.
# If the timeout expires before the connection is established a `TimeoutError` exception will be raised.
#
# @option opts [Boolean] :compress (true) A boolean flag indicating if the SDK should ask the server to send
# compressed responses. Note that this is a hint for the server, and that it may return uncompressed data even
# when this parameter is set to `true`. Also, compression will be automatically disabled when the `debug`
# parameter is set to `true`, as otherwise the debug output will be compressed as well, and then it isn't
# useful.
#
# @option opts [String] :proxy_url A string containing the protocol, address and port number of the proxy server
# to use to connect to the server. For example, in order to use the HTTP proxy `proxy.example.com` that is
# listening on port `3128` the value should be `http://proxy.example.com:3128`. This is optional, and if not
# given the connection will go directly to the server specified in the `url` parameter.
#
# @option opts [String] :proxy_username The name of the user to authenticate to the proxy server.
#
# @option opts [String] :proxy_password The password of the user to authenticate to the proxy server.
#
# @option opts [Hash] :headers Custom HTTP headers to send with all requests. The keys of the hash can be
# strings of symbols, and they will be used as the names of the headers. The values of the hash will be used
# as the names of the headers. If the same header is provided here and in the `headers` parameter of a specific
# method call, then the `headers` parameter of the specific method call will have precedence.
#
# @option opts [Integer] :connections (1) The maximum number of connections to open to the host. The value must
# be greater than 0.
#
# @option opts [Integer] :pipeline (0) The maximum number of request to put in an HTTP pipeline without waiting for
# the response. If the value is `0` (the default) then pipelining is disabled.
#
def initialize(opts = {})
# Get the values of the parameters and assign default values:
@url = opts[:url]
@username = opts[:username]
@password = opts[:password]
@token = opts[:token]
@insecure = opts[:insecure] || false
@ca_file = opts[:ca_file]
@ca_certs = opts[:ca_certs]
@debug = opts[:debug] || false
@log = opts[:log]
@kerberos = opts[:kerberos] || false
@timeout = opts[:timeout] || 0
@connect_timeout = opts[:connect_timeout] || 0
@compress = opts[:compress] || true
@proxy_url = opts[:proxy_url]
@proxy_username = opts[:proxy_username]
@proxy_password = opts[:proxy_password]
@headers = opts[:headers]
@connections = opts[:connections] || 1
@pipeline = opts[:pipeline] || 0
# Check that the URL has been provided:
raise ArgumentError, "The 'url' option is mandatory" unless @url
# Automatically disable compression when debug is enabled, as otherwise the debug output generated by
# libcurl is also compressed, and that isn't useful for debugging:
@compress = false if @debug
# Create a temporary file to store the CA certificates, and populate it with the contents of the 'ca_file' and
# 'ca_certs' options. The file will be removed when the connection is closed.
@ca_store = nil
if @ca_file || @ca_certs
@ca_store = Tempfile.new('ca_store')
@ca_store.write(::File.read(@ca_file)) if @ca_file
if @ca_certs
@ca_certs.each do |ca_cert|
@ca_store.write(ca_cert)
end
end
@ca_store.close
end
# Create the mutex that will be used to prevents simultaneous access to the same HTTP client by multiple threads:
@mutex = Mutex.new
# Create the HTTP client:
@client = HttpClient.new(
insecure: @insecure,
ca_file: @ca_store ? @ca_store.path : nil,
debug: @debug,
log: @log,
timeout: @timeout,
connect_timeout: @connect_timeout,
compress: @compress,
proxy_url: @proxy_url,
proxy_username: @proxy_username,
proxy_password: @proxy_password,
connections: @connections,
pipeline: @pipeline
)
end
#
# Returns a reference to the root of the services tree.
#
# @return [SystemService]
#
def system_service
@system_service ||= SystemService.new(self, '')
end
#
# Returns a reference to the service corresponding to the given path. For example, if the `path` parameter
# is `vms/123/diskattachments` then it will return a reference to the service that manages the disk
# attachments for the virtual machine with identifier `123`.
#
# @param path [String] The path of the service, for example `vms/123/diskattachments`.
# @return [Service]
# @raise [Error] If there is no service corresponding to the given path.
#
def service(path)
system_service.service(path)
end
#
# Sends an HTTP request, making sure that multiple threads are coordinated correctly.
#
# @param request [HttpRequest] The request object containing the details of the HTTP request to send.
#
# @api private
#
def send(request)
@mutex.synchronize { internal_send(request) }
end
#
# Waits for the response to the given request, making sure that multiple threads are coordinated correctly.
#
# @param request [HttpRequest] The request object whose corresponding response you want to wait for.
# @return [HttpResponse] A request object containing the details of the HTTP response received.
#
# @api private
#
def wait(request)
@mutex.synchronize { internal_wait(request) }
end
#
# Tests the connectivity with the server. If connectivity works correctly it returns `true`. If there is any
# connectivity problem it will either return `false` or raise an exception if the `raise_exception` parameter is
# `true`.
#
# @param raise_exception [Boolean]
#
# @param timeout [Integer] (nil) The maximun total time to wait for the test to complete, in seconds. If the value
# is `nil` (the default) then the timeout set globally for the connection will be used.
#
# @return [Boolean]
#
def test(raise_exception = false, timeout = nil)
system_service.get(timeout: timeout)
true
rescue StandardError
raise if raise_exception
false
end
#
# Performs the authentication process and returns the authentication token. Usually there is no need to
# call this method, as authentication is performed automatically when needed. But in some situations it
# may be useful to perform authentication explicitly, and then use the obtained token to create other
# connections, using the `token` parameter of the constructor instead of the user name and password.
#
# @return [String]
#
def authenticate
# rubocop:disable Naming/MemoizedInstanceVariableName
@token ||= create_access_token
# rubocop:enable Naming/MemoizedInstanceVariableName
end
#
# Indicates if the given object is a link. An object is a link if it has an `href` attribute.
#
# @return [Boolean]
#
def link?(object)
!object.href.nil?
end
#
# The `link?` method used to be named `is_link?`, and we need to preserve it for backwards compatibility, but try to
# avoid using it.
#
# @return [Boolean]
#
# @deprecated Please use `link?` instead.
#
alias is_link? link?
#
# Follows the `href` attribute of the given object, retrieves the target object and returns it.
#
# @param object [Type] The object containing the `href` attribute.
# @raise [Error] If the `href` attribute has no value, or the link can't be followed.
#
def follow_link(object)
# Check that the "href" has a value, as it is needed in order to retrieve the representation of the object:
href = object.href
raise Error, "Can't follow link because the 'href' attribute doesn't have a value" if href.nil?
# Check that the value of the "href" attribute is compatible with the base URL of the connection:
prefix = URI(@url).path
prefix += '/' unless prefix.end_with?('/')
unless href.start_with?(prefix)
raise Error, "The URL '#{href}' isn't compatible with the base URL of the connection"
end
# Remove the prefix from the URL, follow the path to the relevant service and invoke the "get" or "list" method
# to retrieve its representation:
path = href[prefix.length..-1]
service = service(path)
if object.is_a?(Array)
service.list
else
service.get
end
end
#
# Releases the resources used by this connection, making sure that multiple threads are coordinated correctly.
#
def close
@mutex.synchronize { internal_close }
end
#
# Checks that the content type of the given response is JSON. If it is JSON then it does nothing. If it isn't
# JSON then it raises an exception.
#
# @param response [HttpResponse] The HTTP response to check.
#
# @api private
#
def check_json_content_type(response)
check_content_type(JSON_CONTENT_TYPE_RE, 'JSON', response)
end
#
# Checks that the content type of the given response is XML. If it is XML then it does nothing. If it isn't
# XML then it raises an exception.
#
# @param response [HttpResponse] The HTTP response to check.
#
# @api private
#
def check_xml_content_type(response)
check_content_type(XML_CONTENT_TYPE_RE, 'XML', response)
end
#
# Creates and raises an error containing the details of the given HTTP response.
#
# @param response [HttpResponse] The HTTP response where the details of the raised error will be taken from.
# @param detail [String, Fault] (nil) The detail of the error. It can be a string or a `Fault` object.
#
# @api private
#
def raise_error(response, detail = nil)
# Check if the detail is a fault:
fault = detail.is_a?(Fault) ? detail : nil
# Build the error message from the response and the fault:
message = ''
unless fault.nil?
unless fault.reason.nil?
message << ' ' unless message.empty?
message << "Fault reason is \"#{fault.reason}\"."
end
unless fault.detail.nil?
message << ' ' unless message.empty?
message << "Fault detail is \"#{fault.detail}\"."
end
end
unless response.nil?
unless response.code.nil?
message << ' ' unless message.empty?
message << "HTTP response code is #{response.code}."
end
unless response.message.nil?
message << ' ' unless message.empty?
message << "HTTP response message is \"#{response.message}\"."
end
end
# If the detail is a string, append it to the message:
if detail.is_a?(String)
message << ' ' unless message.empty?
message << detail
message << '.'
end
# Create and populate the error:
klass = Error
unless response.nil?
case response.code
when 401, 403
klass = AuthError
when 404
klass = NotFoundError
end
end
error = klass.new(message)
error.code = response.code if response
error.fault = fault
raise error
end
#
# Returns a string representation of the connection.
#
# @return [String] The string representation.
#
def inspect
"#<#{self.class.name}:#{@url}>"
end
#
# Returns a string representation of the connection.
#
# @return [String] The string representation.
#
def to_s
inspect
end
#
# Returns a string representation of the connection.
#
# @return [String] The string representation.
#
private
#
# Regular expression used to check JSON content type.
#
# @api private
#
JSON_CONTENT_TYPE_RE = %r{^\s*(application|text)/json\s*(;.*)?$}i.freeze
#
# Regular expression used to check XML content type.
#
# @api private
#
XML_CONTENT_TYPE_RE = %r{^\s*(application|text)/xml\s*(;.*)?$}i.freeze
#
# The typical URL path, used just to generate informative error messages.
#
# @api private
#
TYPICAL_PATH = '/ovirt-engine/api'.freeze
#
# Checks the content type of the given HTTP response and raises an exception if it isn't the expected one.
#
# @param expected_re [Regex] The regular expression used to check the expected content type.
# @param expected_name [String] The name of the expected content type.
# @param response [HttpResponse] The HTTP response to check.
#
# @api private
#
def check_content_type(expected_re, expected_name, response)
content_type = response.headers['content-type']
return if expected_re =~ content_type
detail = "The response content type '#{content_type}' isn't #{expected_name}"
url = URI(@url)
if url.path != TYPICAL_PATH
detail << ". Is the path '#{url.path}' included in the 'url' parameter correct?"
detail << " The typical one is '#{TYPICAL_PATH}'"
end
raise_error(response, detail)
end
#
# Obtains the access token from SSO to be used for bearer authentication.
#
# @return [String] The access token.
#
# @api private
#
def create_access_token
# Build the URL and parameters required for the request:
url, parameters = build_sso_auth_request
# Send the request and wait for the request:
response = get_sso_response(url, parameters)
response = response[0] if response.is_a?(Array)
# Check the response and raise an error if it contains an error code:
error = get_sso_error_message(response)
raise AuthError, "Error during SSO authentication: #{error}" if error
response['access_token']
end
#
# Revoke the SSO access token.
#
# @api private
#
def revoke_access_token
# Build the URL and parameters required for the request:
url, parameters = build_sso_revoke_request
# Send the request and wait for the response:
response = get_sso_response(url, parameters)
response = response[0] if response.is_a?(Array)
# Check the response and raise an error if it contains an error code:
error = get_sso_error_message(response)
raise AuthError, "Error during SSO revoke: #{error}" if error
end
#
# Execute a get request to the SSO server and return the response.
#
# @param url [String] The URL of the SSO server.
#
# @param parameters [Hash] The parameters to send to the SSO server.
#
# @return [Hash] The JSON response.
#
# @api private
#
def get_sso_response(url, parameters)
# Create the request:
request = HttpRequest.new
request.method = :POST
request.url = url
request.headers = {
'User-Agent' => "RubySDK/#{VERSION}",
'Content-Type' => 'application/x-www-form-urlencoded',
'Accept' => 'application/json'
}
request.body = URI.encode_www_form(parameters)
# Add the global headers:
request.headers.merge!(@headers) if @headers
# Send the request and wait for the response:
@client.send(request)
response = @client.wait(request)
raise response if response.is_a?(Exception)
# Check the returned content type:
check_json_content_type(response)
# Parse and return the JSON response:
JSON.parse(response.body)
end
#
# Builds a the URL and parameters to acquire the access token from SSO.
#
# @return [Array] An array containing two elements, the first is the URL of the SSO service and the second is a hash
# containing the parameters required to perform authentication.
#
# @api private
#
def build_sso_auth_request
# Compute the entry point and the parameters:
parameters = {
scope: 'ovirt-app-api'
}
if @kerberos
entry_point = 'token-http-auth'
parameters[:grant_type] = 'urn:ovirt:params:oauth:grant-type:http'
else
entry_point = 'token'
parameters.merge!(
grant_type: 'password',
username: @username,
password: @password
)
end
# Compute the URL:
url = URI(@url.to_s)
url.path = "/ovirt-engine/sso/oauth/#{entry_point}"
url = url.to_s
# Return the pair containing the URL and the parameters:
[url, parameters]
end
#
# Builds a the URL and parameters to revoke the SSO access token
#
# @return [Array] An array containing two elements, the first is the URL of the SSO service and the second is a hash
# containing the parameters required to perform the revoke.
#
# @api private
#
def build_sso_revoke_request
# Compute the parameters:
parameters = {
scope: '',
token: @token
}
# Compute the URL:
url = URI(@url.to_s)
url.path = '/ovirt-engine/services/sso-logout'
url = url.to_s
# Return the pair containing the URL and the parameters:
[url, parameters]
end
#
# Extrats the error message from the given SSO response.
#
# @param response [Hash] The result of parsing the JSON document returned by the SSO server.
# @return [String] The error message, or `nil` if there was no error.
#
def get_sso_error_message(response)
# OAuth uses the 'error_code' attribute for the error code, and 'error' for the error description. But OpenID uses
# 'error' for the error code and 'error_description' for the description. So we need to check if the
# 'error_description' attribute is present, and extract the code and description accordingly.
description = response['error_description']
if description.nil?
code = response['error_code']
description = response['error']
else
code = response['error']
end
"#{code}: #{description}" if code
end
#
# Sends an HTTP request.
#
# @param request [HttpRequest] The request object containing the details of the HTTP request to send.
#
# @api private
#
def internal_send(request)
# Add the base URL to the request:
request.url = request.url.nil? ? request.url = @url : "#{@url}/#{request.url}"
# Set the headers common to all requests:
request.headers.merge!(
'User-Agent' => "RubySDK/#{VERSION}",
'Version' => '4',
'Content-Type' => 'application/xml',
'Accept' => 'application/xml'
)
# Older versions of the engine (before 4.1) required the 'all_content' as an HTTP header instead of a query
# parameter. In order to better support those older versions of the engine we need to check if this parameter is
# included in the request, and add the corresponding header.
unless request.query.nil?
all_content = request.query[:all_content]
request.headers['All-Content'] = all_content unless all_content.nil?
end
# Add the global headers, but without replacing the values that may already exist:
request.headers.merge!(@headers) { |_name, local, _global| local } if @headers
# Set the authentication token:
@token ||= create_access_token
request.token = @token
# Send the request:
@client.send(request)
end
#
# Waits for the response to the given request.
#
# @param request [HttpRequest] The request object whose corresponding response you want to wait for.
# @return [Response] A request object containing the details of the HTTP response received.
#
# @api private
#
def internal_wait(request)
# Wait for the response:
response = @client.wait(request)
raise response if response.is_a?(Exception)
# If the request failed because of authentication, and it wasn't a request to the SSO service, then the
# most likely cause is an expired SSO token. In this case we need to request a new token, and try the original
# request again, but only once. It if fails again, we just return the failed response.
if response.code == 401 && request.token
@token = create_access_token
request.token = @token
@client.send(request)
response = @client.wait(request)
end
response
end
#
# Releases the resources used by this connection.
#
# @api private
#
def internal_close
# Revoke the SSO access token:
revoke_access_token if @token
# Close the HTTP client:
@client.close if @client
# Remove the temporary file that contains the trusted CA certificates:
@ca_store.unlink if @ca_store
end
end
end