# VMC client # # Example: # # require 'vmc' # client = VMC::Client.new('api.vcap.me') # client.login(:user, :pass) # client.create('myapplication', manifest) # client.create_service('aws', 'redis', 'my_redis_service', opts); # require 'rubygems' require 'json/pure' require 'open-uri' require File.expand_path('../const', __FILE__) class VMC::Client def self.version VMC::VERSION end attr_reader :target, :host, :user, :proxy, :auth_token attr_accessor :trace, :infra # Error codes VMC_HTTP_ERROR_CODES = [ 400, 500 ] HTTP_TIMEOUT = ENV['TIMEOUT'] ? ENV['TIMEOUT'].to_i : 10*60 # Errors class BadTarget < RuntimeError; end class AuthError < RuntimeError; end class TargetError < RuntimeError; end class NotFound < RuntimeError; end class BadResponse < RuntimeError; end class HTTPException < RuntimeError; end # Initialize new client to the target_uri with optional auth_token def initialize(target_url=VMC::DEFAULT_TARGET, auth_token=nil) target_url = "https://#{target_url}" unless /^https?/ =~ target_url target_url = target_url.gsub(/\/+$/, '') @target = target_url @auth_token = auth_token end ###################################################### # Target info ###################################################### # Retrieves information on the target cloud, and optionally the logged in user def info # TODO: Should merge for new version IMO, general, services, user_account json_get(VMC::INFO_PATH) end def raw_info http_get(VMC::INFO_PATH) end # Global listing of services that are available on the target system def services_info check_login_status json_get(path(VMC::GLOBAL_SERVICES_PATH)) end def runtimes_info json_get(path(VMC::GLOBAL_RUNTIMES_PATH)) end ###################################################### # Apps ###################################################### def apps check_login_status json_get(VMC::APPS_PATH) end def create_app(name, manifest={}) check_login_status app = manifest.dup app[:name] = name app[:instances] ||= 1 json_post(VMC::APPS_PATH, app) end def update_app(name, manifest) check_login_status json_put(path(VMC::APPS_PATH, name), manifest) end def upload_app(name, zipfile, resource_manifest=nil) #FIXME, manifest should be allowed to be null, here for compatability with old cc's resource_manifest ||= [] check_login_status upload_data = {:_method => 'put'} if zipfile if zipfile.is_a? File file = zipfile else file = File.new(zipfile, 'rb') end upload_data[:application] = file end upload_data[:resources] = resource_manifest.to_json if resource_manifest http_post(path(VMC::APPS_PATH, name, "application"), upload_data) rescue RestClient::ServerBrokeConnection retry end def delete_app(name) check_login_status http_delete(path(VMC::APPS_PATH, name)) end def app_info(name) check_login_status json_get(path(VMC::APPS_PATH, name)) end def app_update_info(name) check_login_status json_get(path(VMC::APPS_PATH, name, "update")) end def app_stats(name) check_login_status stats_raw = json_get(path(VMC::APPS_PATH, name, "stats")) stats = [] stats_raw.each_pair do |k, entry| # Skip entries with no stats next unless entry[:stats] entry[:instance] = k.to_s.to_i entry[:state] = entry[:state].to_sym if entry[:state] stats << entry end stats.sort { |a,b| a[:instance] - b[:instance] } end def app_instances(name) check_login_status json_get(path(VMC::APPS_PATH, name, "instances")) end def app_crashes(name) check_login_status json_get(path(VMC::APPS_PATH, name, "crashes")) end # List the directory or download the actual file indicated by # the path. def app_files(name, path, instance='0') check_login_status path = path.gsub('//', '/') url = path(VMC::APPS_PATH, name, "instances", instance, "files", path) _, body, headers = http_get(url) body end def app_download(name,path) check_login_status url = path(VMC::APPS_PATH, name, "application") status, body, headers = http_get(url,'application/octet-stream') file = File.new(path,"wb") file.write(body) file.close end def app_pull(name, dir) check_login_status url = path(VMC::APPS_PATH, name, "application") status, body, headers = http_get(url,'application/octet-stream') file = Tempfile.new(name) file.binmode file.write(body) file.close ::VMC::Cli::ZipUtil.unpack(file.path, dir) file.unlink end ###################################################### # Services ###################################################### # listing of services that are available in the system def services check_login_status json_get(VMC::SERVICES_PATH) end def create_service(infra,service, name) check_login_status services = services_info services ||= [] service_hash = nil service = service.to_s # FIXME! services.each do |service_type, value| value.each do |vendor, version| version.each do |version_str, service_descr| if service == service_descr[:vendor] service_hash = { :type => service_descr[:type], :tier => 'free', :vendor => service, :version => version_str } service_hash[:infra] = { :provider => infra } if infra break end end end end raise TargetError, "Service [#{service}] is not a valid service choice" unless service_hash service_hash[:name] = name json_post(path(VMC::SERVICES_PATH), service_hash) end def delete_service(name) check_login_status svcs = services || [] names = svcs.collect { |s| s[:name] } raise TargetError, "Service [#{name}] not a valid service" unless names.include? name http_delete(path(VMC::SERVICES_PATH, name)) end def bind_service(service, appname) check_login_status svc = services.detect { |s| s[:name] == service } app = app_info(appname) if infra_supported? && ! infras_match?(app,svc) raise TargetError, "Service #{service} and App #{appname} are not on the same infra" end services = app[:services] || [] app[:services] = services << service update_app(appname, app) end def unbind_service(service, appname) check_login_status app = app_info(appname) services = app[:services] || [] services.delete(service) app[:services] = services update_app(appname, app) end def export_service(service) json_get(path(VMC::SERVICE_EXPORT_PATH, service)) end def import_service(service,uri) json_post(path(VMC::SERVICE_IMPORT_PATH, service),{:uri => uri}) end ###################################################### # Resources ###################################################### # Send in a resources manifest array to the system to have # it check what is needed to actually send. Returns array # indicating what is needed. This returned manifest should be # sent in with the upload if resources were removed. # E.g. [{:sha1 => xxx, :size => xxx, :fn => filename}] def check_resources(resources,infra=nil) check_login_status url = VMC::RESOURCES_PATH unless infra.nil? url += "?infra=#{infra}" end status, body, headers = json_post(url, resources) json_parse(body) end ###################################################### # Validation Helpers ###################################################### # Checks that the target is valid def target_valid? return false unless descr = info return false unless descr[:name] return false unless descr[:build] return false unless descr[:version] return false unless descr[:support] true rescue false end # Checks that the auth_token is valid def logged_in? descr = info if descr return false unless descr[:user] return false unless descr[:usage] @user = descr[:user] true end end ###################################################### # User login/password ###################################################### # login and return an auth_token # Auth token can be retained and used in creating # new clients, avoiding login. def login(user, password) status, body, headers = json_post(path(VMC::USERS_PATH, user, "tokens"), {:password => password}) response_info = json_parse(body) if response_info @user = user @auth_token = response_info[:token] end end # sets the password for the current logged user def change_password(new_password) check_login_status user_info = json_get(path(VMC::USERS_PATH, @user)) if user_info user_info[:password] = new_password json_put(path(VMC::USERS_PATH, @user), user_info) end end ###################################################### # System administration ###################################################### def proxy=(proxy) @proxy = proxy end def proxy_for(proxy) @proxy = proxy end def users check_login_status json_get(VMC::USERS_PATH) end def add_user(user_email, password) json_post(VMC::USERS_PATH, { :email => user_email, :password => password }) end def delete_user(user_email) check_login_status http_delete(path(VMC::USERS_PATH, user_email)) end ###################################################### def self.path(*path) path.flatten.collect { |x| URI.encode x.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]") }.join("/") end ###################################################### # Infrastructure ###################################################### def infras json_get(path(VMC::GLOBAL_INFRAS_PATH)) rescue [] end def infra_supported? !infras.empty? end def base_for_infra(name) info = infras.detect { |i| i[:infra] == name } info ? info[:base] : default_base end def default_base # remove the protocol, and the first component of the url, which is normally api pattern = /https?:\/\/[^.]+\./ if @target =~ pattern @target.sub(pattern,'') else "aws.af.cm" end end def infra_valid?(name) infras.detect { |i| i[:infra] == name } end def infra_descriptions infras.select{|i| !i.has_key?(:available) || i[:available] == true}.map { |i| i[:description] } end def infra_name_for_description(desc) info = infras.detect { |i| i[:description] == desc } info ? info[:infra] : "" end def suggest_url(infra=nil) @suggest_url ||= base_for_infra(infra || @infra) end private def path(*args, &blk) self.class.path(*args, &blk) end def json_get(url) status, body, headers = http_get(url, 'application/json') json_parse(body) rescue JSON::ParserError raise BadResponse, "Can't parse response into JSON", body end def json_post(url, payload) http_post(url, payload.to_json, 'application/json') end def json_put(url, payload) http_put(url, payload.to_json, 'application/json') end def json_parse(str) if str JSON.parse(str, :symbolize_names => true) end end require 'rest_client' # HTTP helpers def http_get(path, content_type=nil) request(:get, path, content_type) end def http_post(path, body, content_type=nil) request(:post, path, content_type, body) end def http_put(path, body, content_type=nil) request(:put, path, content_type, body) end def http_delete(path) request(:delete, path) end def request(method, path, content_type = nil, payload = nil, headers = {}) headers = headers.dup headers['AUTHORIZATION'] = @auth_token if @auth_token headers['PROXY-USER'] = @proxy if @proxy if content_type headers['Content-Type'] = content_type headers['Accept'] = content_type end req = { :method => method, :url => "#{@target}/#{path}", :payload => payload, :headers => headers, :multipart => true, :timeout => HTTP_TIMEOUT, :open_timeout => HTTP_TIMEOUT } status, body, response_headers = perform_http_request(req) if request_failed?(status) # FIXME, old cc returned 400 on not found for file access err = (status == 404 || status == 400) ? NotFound : TargetError raise err, parse_error_message(status, body) else return status, body, response_headers end rescue URI::Error, SocketError, Errno::ECONNREFUSED => e raise BadTarget, "Cannot access target (%s)" % [ e.message ] end def request_failed?(status) VMC_HTTP_ERROR_CODES.detect{|error_code| status >= error_code} end def perform_http_request(req) proxy_uri = URI.parse(req[:url]).find_proxy() RestClient.proxy = proxy_uri.to_s if proxy_uri # Setup tracing if needed unless trace.nil? req[:headers]['X-VCAP-Trace'] = (trace == true ? '22' : trace) end result = nil RestClient::Request.execute(req) do |response, request| result = [ response.code, response.body, response.headers ] unless trace.nil? puts '>>>' puts "PROXY: #{RestClient.proxy}" if RestClient.proxy puts "REQUEST: #{req[:method]} #{req[:url]}" puts "RESPONSE_HEADERS:" response.headers.each do |key, value| puts " #{key} : #{value}" end puts "REQUEST_BODY: #{req[:payload]}" if req[:payload] puts "RESPONSE: [#{response.code}]" begin puts JSON.pretty_generate(JSON.parse(response.body)) rescue puts "#{response.body}" end puts '<<<' end end result rescue Net::HTTPBadResponse => e raise BadTarget "Received bad HTTP response from target: #{e}" rescue SystemCallError, RestClient::Exception => e raise HTTPException, "HTTP exception: #{e.class}:#{e}" end def truncate(str, limit = 30) etc = '...' stripped = str.strip[0..limit] if stripped.length > limit stripped + etc else stripped end end def parse_error_message(status, body) parsed_body = json_parse(body.to_s) if parsed_body && parsed_body[:code] && parsed_body[:description] desc = parsed_body[:description].gsub("\"","'") "Error #{parsed_body[:code]}: #{desc}" else "Error (HTTP #{status}): #{body}" end rescue JSON::ParserError if body.nil? || body.empty? "Error (#{status}): No Response Received" else body_out = trace ? body : truncate(body) "Error (JSON #{status}): #{body_out}" end end def check_login_status raise AuthError unless @user || logged_in? end def infras_match?(o1,o2) o1 && o2 && ( o1[:infra] == o2[:infra]) end end