lib/aviator/core/session.rb
#
# Author:: Mark Maglana (mmaglana@gmail.com)
# Copyright:: Copyright (c) 2014 Mark Maglana
# License:: Distributed under the MIT license
# Homepage:: http://aviator.github.io/www/
#
module Aviator
#
# Manages a provider (e.g. OpenStack) session and serves as the entry point
# for a consumer class/object. See Session::new for notes on usage.
#
class Session
class AuthenticationError < StandardError
def initialize(last_auth_body)
super("Authentication failed. The server returned #{ last_auth_body }")
end
end
class EnvironmentNotDefinedError < ArgumentError
def initialize(path, env)
super("The environment '#{ env }' is not defined in #{ path }.")
end
end
class InitializationError < StandardError
def initialize
super("The session could not find :session_dump, :config_file, and " \
":config in the constructor arguments provided")
end
end
class InvalidConfigFilePathError < ArgumentError
def initialize(path)
super("The config file at #{ path } does not exist!")
end
end
class NotAuthenticatedError < StandardError
def initialize
super("Session is not authenticated. Please authenticate before proceeding.")
end
end
class ValidatorNotDefinedError < StandardError
def initialize
super("The validator request name is not defined for this session object.")
end
end
#
# Create a new Session instance.
#
# <b>Initialize with a config file</b>
#
# Aviator::Session.new(:config_file => 'path/to/aviator.yml', :environment => :production)
#
# In the above example, the config file must have the following form:
#
# production:
# provider: openstack
# auth_service:
# name: identity
# host_uri: 'http://my.openstackenv.org:5000'
# request: create_token
# validator: list_tenants
# api_version: v2
# auth_credentials:
# username: myusername
# password: mypassword
# tenant_name: myproject
#
# <b>SIDENOTE:</b> For more information about the <tt>validator</tt> member, see Session#validate.
#
# Once the session has been instantiated, you may authenticate against the
# provider as follows:
#
# session.authenticate
#
# The members you put under <tt>auth_credentials</tt> will depend on the request
# class you declare under <tt>auth_service:request</tt> and what parameters it
# accepts. To know more about a request class and its parameters, you can use
# the CLI tool <tt>aviator describe</tt> or view the request definition file directly.
#
# If writing the <tt>auth_credentials</tt> in the config file is not acceptable,
# you may omit it and just supply the credentials at runtime. For example:
#
# session.authenticate do |params|
# params.username = ARGV[0]
# params.password = ARGV[1]
# params.tenant_name = ARGV[2]
# end
#
# See Session#authenticate for more info.
#
# Note that while the example config file above only has one environment (production),
# you can declare an arbitrary number of environments in your config file. Shifting
# between environments is as simple as changing the <tt>:environment</tt> to refer to that.
#
#
# <b>Initialize with an in-memory hash</b>
#
# You can create an in-memory hash with a structure similar to the config file but without
# the environment name. For example:
#
# configuration = {
# :provider => 'openstack',
# :auth_service => {
# :name => 'identity',
# :host_uri => 'http://devstack:5000/v2.0',
# :request => 'create_token',
# :validator => 'list_tenants'
# }
# }
#
# Supply this to the initializer using the <tt>:config</tt> option. For example:
#
# Aviator::Session.new(:config => configuration)
#
#
# <b>Initialize with a session dump</b>
#
# You can create a new Session instance using a dump from another instance. For example:
#
# session_dump = session1.dump
# session2 = Aviator::Session.new(:session_dump => session_dump)
#
# However, Session.load is cleaner and recommended over this method.
#
#
# <b>Optionally supply a log file</b>
#
# In all forms above, you may optionally add a <tt>:log_file</tt> option to make
# Aviator write all HTTP calls to the given path. For example:
#
# Aviator::Session.new(:config_file => 'path/to/aviator.yml', :environment => :production, :log_file => 'path/to/log')
#
def initialize(opts={})
if opts.has_key? :session_dump
initialize_with_dump(opts[:session_dump])
elsif opts.has_key? :config_file
initialize_with_config(opts[:config_file], opts[:environment])
elsif opts.has_key? :config
initialize_with_hash(opts[:config])
else
raise InitializationError.new
end
@log_file = opts[:log_file]
end
#
# Authenticates against the backend provider using the auth_service request class
# declared in the session's configuration. Please see Session.new for more information
# on declaring the request class to use for authentication.
#
# <b>Request params block</b>
#
# If the auth_service request class accepts parameters, you may supply that
# as a block and it will be directly passed to the request. For example:
#
# session = Aviator::Session.new(:config => config)
# session.authenticate do |params|
# params.username = username
# params.password = password
# params.tenant_name = project
# end
#
# If your configuration happens to have an <tt>auth_credentials</tt> in it, those
# will be overridden by this block.
#
# <b>Treat parameters as a hash</b>
#
# You can also treat the params struct like a hash with the attribute
# names as the keys. For example, we can rewrite the above as:
#
# session = Aviator::Session.new(:config => config)
# session.authenticate do |params|
# params[:username] = username
# params[:password] = password
# params[:tenant_name] = project
# end
#
# Keys can be symbols or strings.
#
# <b>Use a hash argument instead of a block</b>
#
# You may also provide request params as an argument instead of a block. This is
# especially useful if you want to mock Aviator as it's easier to specify ordinary
# argument expectations over blocks. Further rewriting the example above,
# we end up with:
#
# session = Aviator::Session.new(:config => config)
# session.authenticate :params => {
# :username => username,
# :password => password,
# :tenant_name => project
# }
#
# If both <tt>:params</tt> and a block are provided, the <tt>:params</tt>
# values will be used and the block ignored.
#
# <b>Success requirements</b>
#
# Expects an HTTP status 200 or 201 response from the backend. Any other
# status is treated as a failure.
#
def authenticate(opts={}, &block)
block ||= lambda do |params|
config[:auth_credentials].each do |key, value|
begin
params[key] = value
rescue NameError => e
raise NameError.new("Unknown param name '#{key}'")
end
end
end
response = auth_service.request(config[:auth_service][:request].to_sym, opts, &block)
if [200, 201].include? response.status
@auth_response = Hashish.new({
:headers => response.headers,
:body => response.body
})
update_services_session_data
else
raise AuthenticationError.new(response.body)
end
self
end
#
# Returns true if the session has been authenticated. Note that this relies on
# cached response from a previous run of Session#authenticate if one was made.
# If you want to check against the backend provider if the session is still valid,
# use Session#validate instead.
#
def authenticated?
!auth_response.nil?
end
#
# Returns its configuration.
#
def config
@config
end
#
# Returns a JSON string of its configuration and auth_data. This string can be streamed
# or stored and later re-loaded in another Session instance. For example:
#
# session = Aviator::Session.new(:config => configuration)
# str = session.dump
#
# # time passes...
#
# session = Aviator::Session.load(str)
#
def dump
JSON.generate({
:config => config,
:auth_response => auth_response
})
end
#
# Same as Session::load but re-uses the Session instance this method is
# called on instead of creating a new one.
#
def load(session_dump)
initialize_with_dump(session_dump)
update_services_session_data
self
end
def method_missing(name, *args, &block) # :nodoc:
service_name_parts = name.to_s.match(/^(\w+)_service$/)
if service_name_parts
get_service_obj(service_name_parts[1])
else
super name, *args, &block
end
end
#
# Creates a new Session object from a previous session's dump. See Session#dump for
# more information.
#
# If you want the newly deserialized session to log its output, add a <tt>:log_file</tt>
# option.
#
# Aviator::Session.load(session_dump_str, :log_file => 'path/to/aviator.log')
#
def self.load(session_dump, opts={})
opts[:session_dump] = session_dump
new(opts)
end
#
# Returns the log file path. May be nil if none was provided during initialization.
#
def log_file
@log_file
end
#
# Calls the given request of the given service. An example call might look like:
#
# session.request :compute_service, :create_server do |p|
# p.name = "My Server"
# p.image_ref = "7cae8c8e-fb01-4a88-bba3-ae0fcb1dbe29"
# p.flavor_ref = "fa283da1-59a5-4245-8569-b6eadf69f10b"
# end
#
# Note that you can also treat the block's argument like a hash with the attribute
# names as the keys. For example, we can rewrite the above as:
#
# session.request :compute_service, :create_server do |p|
# p[:name] = "My Server"
# p[:image_ref] = "7cae8c8e-fb01-4a88-bba3-ae0fcb1dbe29"
# p[:flavor_ref] = "fa283da1-59a5-4245-8569-b6eadf69f10b"
# end
#
# Keys can be symbols or strings.
#
# You may also provide parameters as an argument instead of a block. This is
# especially useful when mocking Aviator as it's easier to specify ordinary
# argument expectations over blocks. Further rewriting the example above,
# we end up with:
#
# session.request :compute_service, :create_server, :params => {
# :name => "My Server",
# :image_ref => "7cae8c8e-fb01-4a88-bba3-ae0fcb1dbe29",
# :flavor_ref => "fa283da1-59a5-4245-8569-b6eadf69f10b"
# }
#
# If both <tt>:params</tt> and a block are provided, the values in <tt>:params</tt>
# will be used and the block ignored.
#
# <b>Return Value</b>
#
# The return value will be an instance of Hashish, a lightweight replacement for
# activesupport's HashWithIndifferentAccess, with the following structure:
#
# {
# :status => 200,
# :headers => {
# 'X-Auth-Token' => 'd9186f45ce5446eaa0adc9def1c46f5f',
# 'Content-Type' => 'application/json'
# },
# :body => {
# :some_key => :some_value
# }
# }
#
# Note that the members in <tt>:headers</tt> and <tt>:body</tt> will vary depending
# on the provider and the request that was made.
#
# ---
#
# <b>Request Options</b>
#
# You can further customize how the method behaves by providing one or more
# options to the call. For example, assuming you are using the <tt>openstack</tt>
# provider, the following will call the <tt>:create_server</tt> request of the
# v1 API of <tt>:compute_service</tt>.
#
# session.request :compute_service, :create_server, :api_version => v1, :params => params
#
# The available options vary depending on the provider. See the documentation
# on the provider's Provider class for more information (e.g. Aviator::Openstack::Provider)
#
def request(service_name, request_name, opts={}, &block)
service = send("#{service_name.to_s}_service")
response = service.request(request_name, opts, &block)
response.to_hash
end
#
# Returns true if the session is still valid in the underlying provider. This method calls
# the <tt>validator</tt> request class declared under <tt>auth_service</tt> in the
# configuration. The validator can be any request class as long as:
#
# * The request class exists!
# * Is not an anonymous request. Otherwise it will always return true.
# * Does not require any parameters
# * It returns an HTTP status 200 or 203 to indicate auth info validity.
# * It returns any other HTTP status to indicate that the auth info is invalid.
#
# See Session::new for an example on how to specify the request class to use for session validation.
#
# Note that this method requires the session to be previously authenticated otherwise a
# NotAuthenticatedError will be raised. If you just want to check if the session was previously
# authenticated, use Session#authenticated? instead.
#
def validate
raise NotAuthenticatedError.new unless authenticated?
raise ValidatorNotDefinedError.new unless config[:auth_service][:validator]
auth_with_bootstrap = auth_response.merge({ :auth_service => config[:auth_service] })
response = auth_service.request config[:auth_service][:validator].to_sym, :session_data => auth_with_bootstrap
response.status == 200 || response.status == 203
end
private
def auth_response
@auth_response
end
def auth_service
@auth_service ||= Service.new(
:provider => config[:provider],
:service => config[:auth_service][:name],
:default_session_data => { :auth_service => config[:auth_service] },
:log_file => log_file
)
end
def get_service_obj(service_name)
@services ||= {}
if @services[service_name].nil?
default_options = config["#{ service_name }_service"]
@services[service_name] = Service.new(
:provider => config[:provider],
:service => service_name,
:default_session_data => auth_response,
:default_options => default_options,
:log_file => log_file
)
end
@services[service_name]
end
def initialize_with_config(config_path, environment)
raise InvalidConfigFilePathError.new(config_path) unless Pathname.new(config_path).file?
all_config = Hashish.new(YAML.load_file(config_path))
raise EnvironmentNotDefinedError.new(config_path, environment) unless all_config[environment]
@config = all_config[environment]
end
def initialize_with_dump(session_dump)
session_info = Hashish.new(JSON.parse(session_dump))
@config = session_info[:config]
@auth_response = session_info[:auth_response]
end
def initialize_with_hash(hash_obj)
@config = Hashish.new(hash_obj)
end
def update_services_session_data
return unless @services
@services.each do |name, obj|
obj.default_session_data = auth_response
end
end
end
end