Browse files

squashed commit of caldecott impelementation.

Change-Id: I9d227dd20652719ed67f6bf38c335b929bb33486
  • Loading branch information...
1 parent 3828de0 commit 4242c800c3788472cab9491ce375ee1348e5d294 Patrick Bozeman committed Oct 20, 2011
View
8 .gitignore
@@ -1,4 +1,6 @@
-Gemfile.lock
-.build
+*~
+\#*\#
+.\#*
+.bundle
+spec/coverage
*.gem
-.DS_Store
View
4 Gemfile
@@ -0,0 +1,4 @@
+source "http://rubygems.org"
+
+gemspec
+
View
65 Gemfile.lock
@@ -0,0 +1,65 @@
+PATH
+ remote: .
+ specs:
+ caldecott (0.0.1)
+ addressable (= 2.2.6)
+ async_sinatra (= 0.5.0)
+ em-http-request (= 0.3.0)
+ em-websocket (= 0.3.1)
+ json (= 1.6.1)
+ uuidtools (= 2.1.2)
+
+GEM
+ remote: http://rubygems.org/
+ specs:
+ addressable (2.2.6)
+ async_sinatra (0.5.0)
+ rack (>= 1.2.1)
+ sinatra (>= 1.0)
+ crack (0.3.1)
+ diff-lcs (1.1.3)
+ em-http-request (0.3.0)
+ addressable (>= 2.0.0)
+ escape_utils
+ eventmachine (>= 0.12.9)
+ em-websocket (0.3.1)
+ addressable (>= 2.1.1)
+ eventmachine (>= 0.12.9)
+ escape_utils (0.2.4)
+ eventmachine (0.12.10)
+ json (1.6.1)
+ rack (1.3.5)
+ rack-protection (1.1.4)
+ rack
+ rack-test (0.6.1)
+ rack (>= 1.0)
+ rake (0.9.2)
+ rcov (0.9.10)
+ rspec (2.6.0)
+ rspec-core (~> 2.6.0)
+ rspec-expectations (~> 2.6.0)
+ rspec-mocks (~> 2.6.0)
+ rspec-core (2.6.4)
+ rspec-expectations (2.6.0)
+ diff-lcs (~> 1.1.2)
+ rspec-mocks (2.6.0)
+ sinatra (1.3.1)
+ rack (>= 1.3.4, ~> 1.3)
+ rack-protection (>= 1.1.2, ~> 1.1)
+ tilt (>= 1.3.3, ~> 1.3)
+ tilt (1.3.3)
+ uuidtools (2.1.2)
+ webmock (1.7.6)
+ addressable (~> 2.2, > 2.2.5)
+ crack (>= 0.1.7)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ caldecott!
+ rack-test (= 0.6.1)
+ rake (= 0.9.2)
+ rcov (= 0.9.10)
+ rspec (= 2.6.0)
+ webmock (= 1.7.6)
View
24 Rakefile
@@ -0,0 +1,24 @@
+require 'rake'
+
+desc "Run specs"
+task "spec" => ["bundler:install", "test:spec"]
+
+desc "Run specs using RCov"
+task "spec:rcov" => ["bundler:install", "test:spec:rcov"]
+
+namespace "bundler" do
+ desc "Install gems"
+ task "install" do
+ sh("bundle install")
+ end
+end
+
+namespace "test" do
+ task "spec" do |t|
+ sh("cd spec && rake spec")
+ end
+
+ task "spec:rcov" do |t|
+ sh("cd spec && rake spec:rcov")
+ end
+end
View
15 caldecott.gemspec
@@ -9,11 +9,24 @@ spec = Gem::Specification.new do |s|
s.author = "VMware"
s.email = "support@vmware.com"
s.homepage = "http://vmware.com"
- s.description = s.summary = "TBD"
+ s.description = s.summary = "Caldecott HTTP/Websocket Tunneling Library"
s.platform = Gem::Platform::RUBY
s.extra_rdoc_files = ["README.md", "LICENSE"]
+ s.add_dependency "em-http-request", "= 0.3.0"
+ s.add_dependency "em-websocket", "= 0.3.1"
+ s.add_dependency "async_sinatra", "= 0.5.0"
+ s.add_dependency "addressable", "= 2.2.6"
+ s.add_dependency "json", "= 1.6.1"
+ s.add_dependency "uuidtools", "= 2.1.2"
+
+ s.add_development_dependency "rake", "= 0.9.2"
+ s.add_development_dependency "rcov", "= 0.9.10"
+ s.add_development_dependency "rack-test", "= 0.6.1"
+ s.add_development_dependency "rspec", "= 2.6.0"
+ s.add_development_dependency "webmock", "= 1.7.6"
+
s.require_path = 'lib'
s.files = %w(LICENSE README.md) + Dir.glob("{lib}/**/*")
end
View
11 lib/caldecott.rb
@@ -0,0 +1,11 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'rubygems'
+require 'bundler'
+
+require 'caldecott/client'
+require 'caldecott/server'
+
+require 'caldecott/session_logger'
+require 'caldecott/tcp_connection'
+require 'caldecott/version'
View
7 lib/caldecott/client.rb
@@ -0,0 +1,7 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'caldecott'
+require 'caldecott/client/client'
+require 'caldecott/client/tunnel'
+require 'caldecott/client/http_tunnel'
+require 'caldecott/client/websocket_tunnel'
View
73 lib/caldecott/client/client.rb
@@ -0,0 +1,73 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'eventmachine'
+
+module Caldecott
+ module Client
+ def self.sanitize_url(tun_url)
+ tun_url = tun_url =~ /(http|ws).*/i ? tun_url : "http://#{tun_url}"
+ end
+
+ def self.start(opts)
+ local_port = opts[:local_port]
+ tun_url = opts[:tun_url]
+ dst_host = opts[:dst_host]
+ dst_port = opts[:dst_port]
+ log_file = opts[:log_file]
+ log_level = opts[:log_level]
+
+ trap("TERM") { stop }
+ trap("INT") { stop }
+
+ tun_url = sanitize_url(tun_url)
+
+ EM.run do
+ puts "Starting local server on port #{local_port} to #{tun_url}"
+ EM.start_server("localhost", local_port, TcpConnection) do |conn|
+ # avoid races between tunnel setup and incoming local data
+ conn.pause
+
+ log = SessionLogger.new("client", log_file)
+ log.level = SessionLogger.severity_from_string(log_level)
+
+ tun = nil
+
+ conn.onopen do
+ log.debug "local connected"
+ tun = Tunnel.start(log, tun_url, dst_host, dst_port)
+ end
+
+ tun.onopen do
+ log.debug "tunnel connected"
+ conn.resume
+ end
+
+ conn.onreceive do |data|
+ log.debug "l -> t #{data.length}"
+ tun.send_data(data)
+ end
+
+ tun.onreceive do |data|
+ log.debug("l <- t #{data.length}")
+ conn.send_data(data)
+ end
+
+ conn.onclose do
+ log.debug "local closed"
+ tun.close
+ end
+
+ tun.onclose do
+ log.debug "tunnel closed"
+ conn.close_connection_after_writing
+ end
+ end
+ end
+ end
+
+ def self.stop
+ puts "Caldecott shutting down"
+ EM.stop
+ end
+ end
+end
View
236 lib/caldecott/client/http_tunnel.rb
@@ -0,0 +1,236 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'em-http'
+require 'json'
+
+module Caldecott
+ module Client
+ class HttpTunnel
+ MAX_RETRIES = 10
+
+ def initialize(logger, url, dst_host, dst_port)
+ @log = logger
+ @closing = false
+ @retries = 0
+ init_msg = ""
+
+ # FIXME: why is this optional?
+ if dst_host
+ init_msg = { :host => dst_host, :port => dst_port }.to_json
+ end
+
+ start(url, init_msg)
+ end
+
+ def onopen(&blk)
+ @onopen = blk
+ @onopen.call if @opened
+ end
+
+ def onclose(&blk)
+ @onclose = blk
+ @onclose.call if @closed
+ end
+
+ def onreceive(&blk)
+ @onreceive = blk
+ end
+
+ def send_data(data)
+ @writer.send_data(data)
+ end
+
+ def close
+ return if @closing or @closed
+ @closing = true
+ @writer.close if @writer
+ @reader.close if @reader
+ stop
+ end
+
+ def trigger_on_open
+ @opened = true
+ @onopen.call if @onopen
+ end
+
+ def trigger_on_close
+ close
+ @closed = true
+ @onclose.call if @onclose
+ @onclose = nil
+ end
+
+ def trigger_on_receive(data)
+ @onreceive.call(data)
+ end
+
+ def start(base_uri, init_msg)
+ if (@retries += 1) > MAX_RETRIES
+ trigger_on_close
+ return
+ end
+
+ begin
+ parsed_uri = Addressable::URI.parse(base_uri)
+ parsed_uri.path = '/tunnels'
+
+ @log.debug "post #{parsed_uri.to_s}"
+ req = EM::HttpRequest.new(parsed_uri.to_s).post :body => init_msg
+
+ req.callback do
+ @log.debug "post #{parsed_uri.to_s} #{req.response_header.status}"
+ unless [200, 201, 204].include?(req.response_header.status)
+ start(base_uri, init_msg)
+ else
+ @retries = 0
+ resp = JSON.parse(req.response)
+
+ parsed_uri.path = resp["path"]
+ @tun_uri = parsed_uri.to_s
+
+ parsed_uri.path = resp["path_out"]
+ @reader = Reader.new(@log, parsed_uri.to_s, self)
+
+ parsed_uri.path = resp["path_in"]
+ @writer = Writer.new(@log, parsed_uri.to_s, self)
+ trigger_on_open
+ end
+ end
+
+ req.errback do
+ @log.debug "post #{parsed_uri.to_s} error"
+ start(base_uri, init_msg)
+ end
+
+ rescue Exception => e
+ @log.error e
+ trigger_on_close
+ raise e
+ end
+ end
+
+ def stop
+ if (@retries += 1) > MAX_RETRIES
+ trigger_on_close
+ return
+ end
+
+ return if @tun_uri.nil?
+
+ @log.debug "delete #{@tun_uri}"
+ req = EM::HttpRequest.new("#{@tun_uri}").delete
+
+ req.errback do
+ @log.debug "delete #{@tun_uri} error"
+ stop
+ end
+
+ req.callback do
+ @log.debug "delete #{@tun_uri} #{req.response_header.status}"
+ if [200, 202, 204, 404].include?(req.response_header.status)
+ trigger_on_close
+ else
+ stop
+ end
+ end
+ end
+
+ class Reader
+ def initialize(log, uri, conn)
+ @log, @base_uri, @conn = log, uri, conn
+ @retries = 0
+ @closing = false
+ start
+ end
+
+ def close
+ @closing = true
+ end
+
+ def start(seq = 1)
+ if (@retries += 1) > MAX_RETRIES
+ @conn.trigger_on_close
+ return
+ end
+
+ return if @closing
+ uri = "#{@base_uri}/#{seq}"
+ @log.debug "get #{uri}"
+ req = EM::HttpRequest.new(uri).get :timeout => 0
+
+ req.errback do
+ @log.debug "get #{uri} error"
+ start(seq)
+ end
+
+ req.callback do
+ @log.debug "get #{uri} #{req.response_header.status}"
+ case req.response_header.status
+ when 200
+ @conn.trigger_on_receive(req.response)
+ @retries = 0
+ start(seq + 1)
+ when 404
+ @conn.trigger_on_close
+ else
+ start(seq)
+ end
+ end
+ end
+ end
+
+ class Writer
+ def initialize(log, uri, conn)
+ @log, @uri, @conn = log, uri, conn
+ @retries = 0
+ @seq, @write_buffer = 1, ""
+ @closing = @writing = false
+ end
+
+ def send_data(data)
+ @write_buffer << data
+ send_data_buffered
+ end
+
+ def close
+ @closing = true
+ end
+
+ def send_data_buffered
+ if (@retries += 1) > MAX_RETRIES
+ @conn.trigger_on_close
+ return
+ end
+
+ return if @closing
+ data, @write_buffer = @write_buffer, "" unless @writing
+
+ @writing = true
+ uri = "#{@uri}/#{@seq}"
+ @log.debug "put #{uri}"
+ req = EM::HttpRequest.new(uri).put :body => data
+
+ req.errback do
+ @log.debug "put #{uri} error"
+ send_data_buffered
+ end
+
+ req.callback do
+ @log.debug "put #{uri} #{req.response_header.status}"
+ case req.response_header.status
+ when 200, 202, 204
+ @writing = false
+ @seq += 1
+ @retries = 0
+ send_data_buffered unless @write_buffer.empty?
+ when 404
+ @conn.trigger_on_close
+ else
+ send_data_buffered
+ end
+ end
+ end
+ end
+ end
+ end
+end
View
23 lib/caldecott/client/tunnel.rb
@@ -0,0 +1,23 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'addressable/uri'
+
+module Caldecott
+ module Client
+ module Tunnel
+ # Note: I wanted to do this with self#new but had issues
+ # with getting send :initialize to figure out the right
+ # number of arguments
+ def self.start(logger, tun_url, dst_host, dst_port)
+ case Addressable::URI.parse(tun_url).normalized_scheme
+ when "http"
+ HttpTunnel.new(logger, tun_url, dst_host, dst_port)
+ when "ws"
+ WebSocketTunnel.new(logger, tun_url, dst_host, dst_port)
+ else
+ raise "invalid url"
+ end
+ end
+ end
+ end
+end
View
36 lib/caldecott/client/websocket_tunnel.rb
@@ -0,0 +1,36 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'em-http'
+
+module Caldecott
+ module Client
+ class WebSocketTunnel
+ def initialize(logger, url, dst_host, dst_port)
+ @ws = EM::HttpRequest.new("#{url}/websocket/#{dst_host}/#{dst_port}").get :timeout => 0
+ end
+
+ def onopen(&blk)
+ @ws.callback { blk.call }
+ end
+
+ def onclose(&blk)
+ @ws.errback { blk.call }
+ @ws.disconnect { blk.call }
+ end
+
+ def onreceive(&blk)
+ @ws.stream { |data| blk.call(Base64.decode64(data)) }
+ end
+
+ def send_data(data)
+ # Um.. as soon as the em websocket object adds a better named
+ # method for this, start using it.
+ @ws.send(Base64.encode64(data))
+ end
+
+ def close
+ @ws.close_connection_after_writing
+ end
+ end
+ end
+end
View
4 lib/caldecott/server.rb
@@ -0,0 +1,4 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'caldecott/server/http_tunnel'
+require 'caldecott/server/websocket_tunnel'
View
209 lib/caldecott/server/http_tunnel.rb
@@ -0,0 +1,209 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'rubygems'
+require 'logger'
+require 'sinatra'
+require 'sinatra/async'
+require 'json'
+require 'uuidtools'
+require 'eventmachine'
+require 'caldecott/tcp_connection.rb'
+require 'caldecott/session_logger.rb'
+
+module Caldecott
+ module Server
+ class Tunnel
+ attr_reader :tun_id, :log, :last_active_at
+ DEFAULT_MAX_DATA_TO_BUFFER = 1 * 1024 * 1024 # 1MB
+
+ def initialize(log, tunnels, host, port, max_data_to_buffer = DEFAULT_MAX_DATA_TO_BUFFER)
+ @log, @tunnels, @host, @port = log, tunnels, host, port
+ @tun_id = UUIDTools::UUID.random_create.to_s
+ @data = @data_next = ""
+ @seq_out = @seq_in = 0
+ @max_data_to_buffer = max_data_to_buffer
+ @last_active_at = Time.now
+ end
+
+ def open(resp)
+ EM::connect(@host, @port, TcpConnection) do |dst_conn|
+ @dst_conn = dst_conn
+
+ @dst_conn.onopen do
+ @log.debug "dst connected"
+ @tunnels[@tun_id] = self
+ resp.content_type :json
+ resp.status 201
+ resp.body safe_hash.to_json
+ end
+
+ @dst_conn.onreceive do |data|
+ @log.debug "t <- d #{data.length}"
+ @data_next << data
+ trigger_reader
+ @dst_conn.pause if @data_next.length > @max_data_to_buffer
+ end
+
+ @dst_conn.onclose do
+ @log.debug "target disconnected"
+ @dst_conn = nil
+ trigger_reader
+ @tunnels.delete(@tun_id) if @data_next.empty?
+ end
+ end
+ @tunnel_created_at = Time.now
+ end
+
+ def delete
+ @log.debug "target disconnected"
+ if @dst_conn
+ @dst_conn.close_connection_after_writing
+ else
+ @tunnels.delete(@tun_id)
+ end
+ end
+
+ def get(resp, seq)
+ @last_active_at = Time.now
+ resp.halt(400, "invalid sequence #{seq} for server seq #{@seq_out}") unless (seq == @seq_out or seq == @seq_out + 1)
+ if seq == @seq_out + 1
+ @data, @data_next = @data_next, ""
+ @seq_out = seq
+ end
+
+ if @data.empty?
+ resp.halt(410, "destination socket closed\n") if @dst_conn.nil?
+ @log.debug "get: waiting for data"
+ @reader = EM.Callback do
+ @data, @data_next = @data_next, ""
+ resp.ahalt(410, "destination socket closed\n") if @data.empty?
+ @log.debug "get: returning data (async)"
+ resp.body @data
+ end
+ else
+ @log.debug "get: returning data (immediate)"
+ resp.body @data
+ @dst_conn.resume
+ end
+ end
+
+ def put(resp, seq)
+ @last_active_at = Time.now
+ resp.halt(400, "invalid sequence #{seq} for server seq #{@seq_in}") unless (seq == @seq_in or seq == @seq_in + 1)
+ if seq == @seq_in
+ resp.status 201
+ else
+ @seq_in = seq
+ @log.debug "t -> d #{resp.request.body.length}"
+ @dst_conn.send_data(resp.request.body.read)
+ resp.status 202
+ end
+ end
+
+ def trigger_reader
+ return unless @reader
+ reader = @reader
+ @reader = nil
+ reader.call
+ end
+
+ def safe_hash
+ {
+ :path => "/tunnels/#{@tun_id}",
+ :path_in => "/tunnels/#{@tun_id}/in",
+ :path_out => "/tunnels/#{@tun_id}/out",
+ :dst_host => @host,
+ :dst_port => @port,
+ :dst_connected => @dst_conn.nil? == false,
+ :seq_out => @seq_out,
+ :seq_in => @seq_in
+ }
+ end
+
+ end
+
+ class HttpTunnel < Sinatra::Base
+ register Sinatra::Async
+
+ @@tunnels = {}
+
+ def self.tunnels
+ @@tunnels
+ end
+
+ # defaults are 1 hour of inactivity with sweeps every 5 minutes
+ def self.start_timer(inactive_timeout = 3600, sweep_interval = 300)
+ EventMachine::add_periodic_timer sweep_interval do
+ # This is needed because there seems to have a bug on the
+ # Connection#set_comm_inactivity_timeout (int overflow )
+ # Look at eventmachine/ext/em.cpp 2289
+ # It reaps the inactive connections
+ #
+ # We also can not seem to add our own timer per tunnel instance.
+ # When we do, the ruby interpreter freaks out and starts throwing
+ #
+ # errors like:
+ # undefined method `cancel' for 57:Fixnum
+ #
+ # for code like the following during shutdown:
+ # @inactivity_timer.cancel if @inactivity_timer
+ # @inactivity_timer.cancel
+ # @inactivity_timer = nil
+ @@tunnels.each do |id, t|
+ t.delete if (Time.now - t.last_active_at) > inactive_timeout
+ end
+ end
+ end
+
+ def tunnel_from_id(tun_id)
+ tun = @@tunnels[tun_id]
+ not_found("tunnel #{tun_id} does not exist\n") unless tun
+ tun.log.debug "#{request.request_method} #{request.url}"
+ tun
+ end
+
+ get '/' do
+ return "Caldecott Tunnel (HTTP Transport) #{VERSION}\n"
+ end
+
+ get '/tunnels' do
+ content_type :json
+ resp = @@tunnels.values.collect { |t| t.safe_hash }
+ resp.to_json
+ end
+
+ apost '/tunnels' do
+ log = SessionLogger.new("server", STDOUT)
+ log.debug "#{request.request_method} #{request.url}"
+ req = JSON.parse(request.body.read, :symbolize_names => true)
+ Tunnel.new(log, @@tunnels, req[:host], req[:port]).open(self)
+ end
+
+ get '/tunnels/:tun' do |tun_id|
+ log = SessionLogger.new("server", STDOUT)
+ log.debug "#{request.request_method} #{request.url}"
+ tun = tunnel_from_id(tun_id)
+ tun.safe_hash.to_json
+ end
+
+ delete '/tunnels/:tun' do |tun_id|
+ log = SessionLogger.new("server", STDOUT)
+ log.debug "#{request.request_method} #{request.url}"
+ tun = tunnel_from_id(tun_id)
+ tun.delete
+ end
+
+ aget '/tunnels/:tun_id/out/:seq' do |tun_id, seq|
+ tun = tunnel_from_id(tun_id)
+ seq = seq.to_i
+ tun.get(self, seq)
+ end
+
+ put '/tunnels/:tun_id/in/:seq' do |tun_id, seq|
+ tun = tunnel_from_id(tun_id)
+ seq = seq.to_i
+ tun.put(self, seq)
+ end
+ end
+ end
+end
View
57 lib/caldecott/server/websocket_tunnel.rb
@@ -0,0 +1,57 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'em-websocket'
+require 'base64'
+
+module Caldecott
+ module Server
+ class WebSocketTunnel
+
+ # quack like sinatra
+ def self.run!(opts)
+ WebSocketTunnel.new.start(opts[:port])
+ end
+
+ def start(port)
+ EM::WebSocket.start(:host => "0.0.0.0", :port => port) do |ws|
+ log = SessionLogger::new("server", STDOUT)
+ dst_conn = nil
+
+ ws.onopen do
+ log.debug "tunnel connected"
+ slash, tunnel, host, port = ws.request['Path'].split('/')
+
+ EM::connect(host, port, TcpConnection) do |d|
+ dst_conn = d
+
+ dst_conn.onopen do
+ log.debug "target connected"
+ end
+
+ dst_conn.onreceive do |data|
+ log.debug("t <- d #{data.length}")
+ ws.send(Base64.encode64(data))
+ end
+
+ dst_conn.onclose do
+ log.debug "target disconnected"
+ ws.close_connection
+ end
+ end
+ end
+
+ ws.onmessage do |msg|
+ decoded = Base64.decode64(msg)
+ log.debug("t -> d #{decoded.length}")
+ dst_conn.send_data(decoded)
+ end
+
+ ws.onclose do
+ log.debug "tunnel disconnected"
+ dst_conn.close_connection_after_writing if dst_conn
+ end
+ end
+ end
+ end
+ end
+end
View
39 lib/caldecott/session_logger.rb
@@ -0,0 +1,39 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'logger'
+
+module Caldecott
+ class SessionLogger < Logger
+ attr_reader :component, :session
+ @@session = 0
+
+ def initialize(component, *args)
+ super(*args)
+ @component = component
+ @session = @@session += 1
+ end
+
+ def format_message(severity, timestamp, progname, msg)
+ "#{@component} [#{@session}] #{msg}\n"
+ end
+
+ def self.severity_from_string(str)
+ case str.upcase
+ when 'DEBUG'
+ Logger::DEBUG
+ when 'INFO'
+ Logger::INFO
+ when 'WARN'
+ Logger::WARN
+ when 'ERROR'
+ Logger::ERROR
+ when 'FATAL'
+ Logger::FATAL
+ when 'UNKNOWN'
+ Logger::UNKNOWN
+ else
+ Logger::ERROR
+ end
+ end
+ end
+end
View
38 lib/caldecott/tcp_connection.rb
@@ -0,0 +1,38 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require 'eventmachine'
+
+module Caldecott
+ # wrapper to avoid callback and state passing spaghetti
+ class TcpConnection < EventMachine::Connection
+ @initialzied = false
+
+ # callbacks
+ def onopen(&blk)
+ @initialized ? blk.call : @onopen = blk
+ end
+
+ def onreceive(&blk)
+ @onreceive = blk
+ end
+
+ def onclose(&blk)
+ @onclose = blk
+ end
+
+ # handle EventMachine::Connection methods
+ def post_init
+ @initialized = true
+ @onopen.call if @onopen
+ end
+
+ def receive_data(data)
+ @onreceive.call(data) if @onreceive
+ end
+
+ def unbind
+ @onclose.call if @onclose
+ end
+
+ end
+end
View
26 spec/Rakefile
@@ -0,0 +1,26 @@
+require 'tempfile'
+
+require 'rubygems'
+require 'bundler/setup'
+Bundler.require(:default, :test)
+
+require 'rake'
+require 'rspec'
+require 'rspec/core/rake_task'
+
+coverage_dir = File.expand_path(File.join(File.dirname(__FILE__), "coverage"))
+
+ignore_pattern = 'spec,[.]bundle,[/]gems[/]'
+
+RSpec::Core::RakeTask.new do |t|
+ t.pattern = "**/*_spec.rb"
+ t.rspec_opts = ["--format", "documentation", "--colour"]
+end
+
+desc "Run specs using RCov"
+RSpec::Core::RakeTask.new("spec:rcov") do |t|
+ t.pattern = "**/*_spec.rb"
+ t.rspec_opts = []
+ t.rcov = true
+ t.rcov_opts = %W{--exclude osx\/objc,gems\/,spec\/,features\/ -o "#{coverage_dir}"}
+end
View
247 spec/client/http_tunnel_spec.rb
@@ -0,0 +1,247 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require File.join(File.dirname(__FILE__), 'spec_helper')
+
+describe 'Client HTTP Tunnel' do
+ include Caldecott::Test
+ include Caldecott::Client::Test
+
+ before do
+ @log = Logger.new StringIO.new
+ @host = 'foo'
+ @port = 12345
+ @base_url = 'http://caldecott.cloudfoundry.com'
+ @times_called = {}
+ tunnel_callbacks.each { |c| @times_called[c] = 0 }
+ end
+
+ after do
+ @validate.call if @validate
+ end
+
+ it 'should attempt to retry timed out connections' do
+ with_em_timeout do
+ stub_request(:post, "#{@base_url}/tunnels").to_timeout
+ tunnel = Caldecott::Client::HttpTunnel.new(@log, @base_url, @host, @port)
+ setup_tunnel_callbacks tunnel, :stop_onclose => true
+
+ @validate = lambda do
+ a_request(:post, "#{@base_url}/tunnels").should have_been_made.times(Caldecott::Client::HttpTunnel::MAX_RETRIES)
+ @times_called[:onclose].should == 1
+ end
+ end
+ end
+
+ it 'should attempt to retry connections that receive HTTP 400 errors' do
+ simulate_error_on_connect :response_code => 400
+ end
+
+ it 'should attempt to retry connections that receive HTTP 500 errors' do
+ simulate_error_on_connect :response_code => 500
+ end
+
+ it 'should attempt to retry connections that raise exceptions' do
+ simulate_error_on_connect :raise => StandardError
+ end
+
+ it 'should successfully connect' do
+ with_em_timeout do
+ tunnel = simulate_successful_connect
+ setup_tunnel_callbacks tunnel, :stop_onopen => true
+ @validate = lambda do
+ @times_called[:onopen].should == 1
+ @times_called[:onclose].should == 0
+ a_request(:post, "#{@base_url}/tunnels").should have_been_made.once
+ end
+ end
+ end
+
+ describe '#onreceive' do
+ it 'should register the onreceive handler and receive data' do
+ with_em_timeout do
+ data = "some data to receive"
+ received = nil
+ tunnel = simulate_successful_connect
+ setup_tunnel_callbacks(tunnel,
+ :stop_onreceive => true,
+ :onreceive => lambda { |d| received = d })
+ tunnel.trigger_on_receive data
+ @validate = lambda do
+ @times_called[:onopen].should == 1
+ @times_called[:onclose].should == 0
+ @times_called[:onreceive].should == 1
+ received.should == data
+ a_request(:post, "#{@base_url}/tunnels").should have_been_made.once
+ end
+ end
+ end
+ end
+
+ describe '#send_data' do
+ it 'should forward data to the writter' do
+ with_em_timeout do
+ data = "some data to send"
+ tunnel = simulate_successful_connect
+ setup_tunnel_callbacks tunnel
+ EM.next_tick { tunnel.send_data data }
+ @writer.should_receive(:send_data).with(data) { EM.stop }
+ end
+ end
+ end
+
+ describe '#close' do
+ before do
+ @tunnel = simulate_successful_connect
+ setup_tunnel_callbacks @tunnel, :stop_onclose => true
+ @writer.should_receive(:close)
+ end
+
+ it 'should attempt to retry timed out connections' do
+ with_em_timeout do
+ stub_request(:delete, "#{@base_url}/#{@path}").to_timeout
+ EM.next_tick { @tunnel.close }
+
+ @validate = lambda do
+ a_request(:delete, "#{@base_url}/#{@path}").should have_been_made.times(Caldecott::Client::HttpTunnel::MAX_RETRIES)
+ @times_called[:onclose].should == 1
+ end
+ end
+ end
+
+ it 'should attempt to retry deletes that receive HTTP 400 errors' do
+ simulate_error_on_delete :response_code => 400
+ end
+
+ it 'should attempt to retry deletes that receive HTTP 500 errors' do
+ simulate_error_on_delete :response_code => 500
+ end
+
+ it 'should successfully delete the tunnel' do
+ with_em_timeout do
+ stub_request(:delete, "#{@base_url}/#{@path}")
+ EM.next_tick { @tunnel.close }
+ @validate = lambda do
+ @times_called[:onclose].should == 1
+ a_request(:delete, "#{@base_url}/#{@path}").should have_been_made.once
+ end
+ end
+ end
+ end
+
+ describe 'Reader' do
+ before do
+ @conn = mock(Caldecott::Client::HttpTunnel)
+ @uri = "http://bla.com/some_tunnel_uri/in"
+ end
+
+ it 'should attempt to retry timed out connections' do
+ with_em_timeout do
+ @conn.should_receive(:trigger_on_close) { EM.stop }
+ stub_request(:get, "#{@uri}/1").to_timeout
+ reader = Caldecott::Client::HttpTunnel::Reader.new(@log, @uri, @conn)
+
+ @validate = lambda do
+ a_request(:get, "#{@uri}/1").should have_been_made.times(Caldecott::Client::HttpTunnel::MAX_RETRIES)
+ end
+ end
+ end
+
+ it 'should attempt to retry deletes that receive HTTP 400 errors' do
+ simulate_error_on_get :response_code => 400
+ end
+
+ it 'should attempt to retry deletes that receive HTTP 500 errors' do
+ simulate_error_on_get :response_code => 500
+ end
+
+ it 'should immediately close connections that receive HTTP 404 errors' do
+ simulate_error_on_get :response_code => 404, :requests_expected => 1
+ end
+
+ it 'should return data and advance the sequence number' do
+ with_em_timeout do
+ reader = nil
+ data = 'some data received by the reader'
+ more_data = 'some more data received by the reader'
+ @conn.should_receive(:trigger_on_receive).with(data).ordered
+ @conn.should_receive(:trigger_on_receive).with(more_data).ordered do
+ reader.close
+ EM.stop
+ end
+
+ stub_request(:get, "#{@uri}/1").to_return(:status => 200, :body => data)
+ stub_request(:get, "#{@uri}/2").to_return(:status => 200, :body => more_data)
+ reader = Caldecott::Client::HttpTunnel::Reader.new(@log, @uri, @conn)
+ end
+ end
+ end
+
+ describe 'Writer' do
+ before do
+ @conn = mock(Caldecott::Client::HttpTunnel)
+ @uri = "http://bla.com/some_tunnel_uri/out"
+ end
+
+ it 'should attempt to retry timed out connections' do
+ with_em_timeout do
+ data = 'some data to send via the writer'
+ @conn.should_receive(:trigger_on_close) { EM.stop }
+ stub_request(:put, "#{@uri}/1").to_timeout
+ writer = Caldecott::Client::HttpTunnel::Writer.new(@log, @uri, @conn)
+ EM.next_tick { writer.send_data data }
+
+ @validate = lambda do
+ a_request(:put, "#{@uri}/1").should have_been_made.times(Caldecott::Client::HttpTunnel::MAX_RETRIES)
+ end
+ end
+ end
+
+ it 'should attempt to retry deletes that receive HTTP 400 errors' do
+ simulate_error_on_put :response_code => 400
+ end
+
+ it 'should attempt to retry deletes that receive HTTP 500 errors' do
+ simulate_error_on_put :response_code => 500
+ end
+
+ it 'should immediately close connections that receive HTTP 404 errors' do
+ simulate_error_on_put :response_code => 404, :requests_expected => 1
+ end
+
+ it 'should send data and advance the sequence number' do
+ with_em_timeout do
+ writer = nil
+ data = 'some data sent by the writer'
+ more_data = 'some more data sent by the writer'
+ writer = Caldecott::Client::HttpTunnel::Writer.new(@log, @uri, @conn)
+ EM.next_tick do
+ writer.send_data data
+ EM.next_tick do
+ writer.send_data more_data
+ EM.stop
+ end
+ end
+
+ stub_request(:put, "#{@uri}/1")
+ stub_request(:put, "#{@uri}/2")
+
+ @validate = lambda do
+ a_request(:put, "#{@uri}/1").with(:body => data).should have_been_made.once
+ a_request(:put, "#{@uri}/2").with(:body => more_data).should have_been_made.once
+ end
+ end
+ end
+
+ it 'should not send data when closing' do
+ with_em_timeout do
+ data = 'some data that should not get sent'
+ writer = Caldecott::Client::HttpTunnel::Writer.new(@log, @uri, @conn)
+ EM.next_tick do
+ writer.close
+ writer.send_data data
+ EM.next_tick { EM.stop }
+ end
+ end
+ end
+ end
+end
View
126 spec/client/spec_helper.rb
@@ -0,0 +1,126 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require File.join(File.dirname(__FILE__), '..', 'spec_helper')
+require 'webmock/rspec'
+require "sinatra/async/test"
+require 'caldecott/client/http_tunnel.rb'
+
+module Caldecott
+ module Client
+ module Test
+
+ def tunnel_callbacks
+ [:onopen, :onclose, :onreceive]
+ end
+
+ def setup_tunnel_callback(tunnel, callback, opts)
+ tunnel.send(callback) do |*args|
+ @times_called[callback] += 1
+ opts[callback].call(*args) if opts[callback]
+ EM.stop if opts["stop_".concat(callback.to_s).to_sym]
+ end
+ end
+
+ def setup_tunnel_callbacks(tunnel, opts = {})
+ tunnel_callbacks.each { |c| setup_tunnel_callback(tunnel, c, opts) }
+ end
+
+ def simulate_error_on_connect(opts)
+ with_em_timeout do
+ # unfortunately, to_return(bla).times(n) isn't working, so we can't test
+ # recovering from errors
+ @request = stub_request(:post, "#{@base_url}/tunnels")
+ @request.to_return(:status => opts[:response_code]) if opts[:response_code]
+ @request.to_raise(opts[:raise]) if opts[:raise]
+
+ if opts[:raise]
+ lambda { Caldecott::Client::HttpTunnel.new(@log, @base_url, @host, @port) }.should raise_exception
+ @validate = lambda do
+ @times_called[:onopen].should == 0
+ @times_called[:onclose].should == 0
+ # FIXME: this is questionable. What exceptions can really be
+ # returned here? Should we be retrying? At a minimum, we should
+ # document the exception block in Tunnel#start
+ a_request(:post, "#{@base_url}/tunnels").should have_been_made.once
+ end
+ EM.stop
+ else
+ tunnel = Caldecott::Client::HttpTunnel.new(@log, @base_url, @host, @port)
+ setup_tunnel_callbacks tunnel, :stop_onclose => true
+
+ @validate = lambda do
+ @times_called[:onopen].should == 0
+ @times_called[:onclose].should == 1
+ a_request(:post, "#{@base_url}/tunnels").should have_been_made.times(Caldecott::Client::HttpTunnel::MAX_RETRIES)
+ end
+ end
+ end
+ end
+
+ def simulate_successful_connect
+ @path = "sometunnel"
+ @path_out = "#{@path}/out"
+ @path_in = "#{@path}/in"
+
+ response_body = { :path => @path,
+ :path_out => @path_out,
+ :path_in => @path_in }.to_json
+
+ stub_request(:post, "#{@base_url}/tunnels").to_return(:body => response_body)
+
+ @writer = mock(Caldecott::Client::HttpTunnel::Writer)
+
+ Caldecott::Client::HttpTunnel::Reader.should_receive(:new).with(@log, "#{@base_url}/#{@path_out}", instance_of(Caldecott::Client::HttpTunnel))
+ Caldecott::Client::HttpTunnel::Writer.should_receive(:new).with(@log, "#{@base_url}/#{@path_in}", instance_of(Caldecott::Client::HttpTunnel)).and_return(@writer)
+ Caldecott::Client::HttpTunnel.new(@log, @base_url, @host, @port)
+ end
+
+ def simulate_error_on_delete(opts)
+ with_em_timeout do
+ @request = stub_request(:delete, "#{@base_url}/#{@path}")
+ @request.to_return(:status => opts[:response_code]) if opts[:response_code]
+
+ EM.next_tick { @tunnel.close }
+ @validate = lambda do
+ @times_called[:onclose].should == 1
+ a_request(:delete, "#{@base_url}/#{@path}").should have_been_made.times(Caldecott::Client::HttpTunnel::MAX_RETRIES)
+ end
+ end
+ end
+
+ def simulate_error_on_get(opts)
+ with_em_timeout do
+ @conn.should_receive(:trigger_on_close) { EM.stop }
+ @request = stub_request(:get, "#{@uri}/1")
+ @request.to_return(:status => opts[:response_code]) if opts[:response_code]
+ reader = Caldecott::Client::HttpTunnel::Reader.new(@log, @uri, @conn)
+
+ @validate = lambda do
+ requests_expected = opts[:requests_expected]
+ requests_expected ||= Caldecott::Client::HttpTunnel::MAX_RETRIES
+ a_request(:get, "#{@uri}/1").should have_been_made.times(requests_expected)
+ end
+ end
+ end
+
+ def simulate_error_on_put(opts)
+ with_em_timeout do
+ data = 'some data to send via the writer'
+ @conn.should_receive(:trigger_on_close) { EM.stop }
+ @request = stub_request(:put, "#{@uri}/1")
+ @request.to_return(:status => opts[:response_code]) if opts[:response_code]
+ writer = Caldecott::Client::HttpTunnel::Writer.new(@log, @uri, @conn)
+ EM.next_tick { writer.send_data data }
+
+ @validate = lambda do
+ requests_expected = opts[:requests_expected]
+ requests_expected ||= Caldecott::Client::HttpTunnel::MAX_RETRIES
+ a_request(:put, "#{@uri}/1").should have_been_made.times(requests_expected)
+ end
+ end
+ end
+
+ end
+ end
+end
+
View
32 spec/client/tunnel_spec.rb
@@ -0,0 +1,32 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require File.join(File.dirname(__FILE__), 'spec_helper')
+require 'caldecott/client/tunnel.rb'
+require 'caldecott/client/http_tunnel.rb'
+require 'caldecott/client/websocket_tunnel.rb'
+
+describe 'Client Tunnel' do
+ before do
+ @log = Logger.new StringIO.new
+ @host = 'foo'
+ @port = 12345
+ end
+
+ # FIXME: we need an https and wss test
+
+ it 'should start an http tunnel when given a http url' do
+ url = 'http://tunnel.com/'
+ Caldecott::Client::HttpTunnel.should_receive(:new).once.with(@log, url, @host, @port)
+ Caldecott::Client::Tunnel.start(@log, url, @host, @port)
+ end
+
+ it 'should start a websocket tunnel when given a websocket url' do
+ url = 'ws://tunnel.com/'
+ Caldecott::Client::WebSocketTunnel.should_receive(:new).once.with(@log, url, @host, @port)
+ Caldecott::Client::Tunnel.start(@log, url, @host, @port)
+ end
+
+ it 'should raise an error when given an invalid url' do
+ lambda { Caldecott::Client::Tunnel.start(@log, 'wtf://tunnel.com/', @host, @port) }.should raise_exception
+ end
+end
View
71 spec/client/websocket_tunnel_spec.rb
@@ -0,0 +1,71 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require File.join(File.dirname(__FILE__), 'spec_helper')
+require 'caldecott/client/websocket_tunnel.rb'
+
+describe 'Client WebSocket Tunnel' do
+
+ before do
+ @log = Logger.new StringIO.new
+ @host = 'foo'
+ @port = 12345
+ @base_url = 'ws://tunnel/'
+
+ @ws_request = mock(EM::HttpRequest)
+ @ws_request.should_receive(:get).once.and_return(@ws_request)
+
+ EM::HttpRequest.should_receive(:new).once.with("#{@base_url}/websocket/#{@host}/#{@port}").and_return(@ws_request)
+
+ @tunnel = Caldecott::Client::WebSocketTunnel.new(@log, @base_url, @host, @port)
+ end
+
+ describe '#onopen' do
+ it 'should register the onopen handler' do
+ @ws_request.should_receive(:callback) { |*args, &blk| @ws_callback = blk }
+ times_called = 0
+ @tunnel.onopen { times_called += 1 }
+ @ws_callback.call
+ times_called.should == 1
+ end
+ end
+
+ describe '#onclose' do
+ it 'should register the onclose handler' do
+ @ws_request.should_receive(:errback) { |*args, &blk| @ws_errback = blk }
+ @ws_request.should_receive(:disconnect) { |*args, &blk| @ws_disconnect = blk }
+ times_called = 0
+ @tunnel.onclose { times_called += 1 }
+ @ws_errback.call
+ times_called.should == 1
+ @ws_disconnect.call
+ times_called.should == 2
+ end
+ end
+
+ describe '#onreceive' do
+ it 'should receive data from the websocket' do
+ msg = "hi there for receive"
+ @ws_request.should_receive(:stream) { |*args, &blk| @ws_stream = blk }
+ received = nil
+ @tunnel.onreceive { |data| received = data }
+ @ws_stream.call Base64.encode64(msg)
+ received.should == msg
+ end
+ end
+
+ describe '#send_data' do
+ it 'should send data to the websocket' do
+ msg = "hi there for send"
+ @ws_request.should_receive(:send).with(Base64.encode64(msg))
+ @tunnel.send_data(msg)
+ end
+ end
+
+ describe '#close' do
+ it 'should close the websocket' do
+ @ws_request.should_receive(:close_connection_after_writing)
+ @tunnel.close
+ end
+ end
+
+end
View
308 spec/server/http_tunnel_spec.rb
@@ -0,0 +1,308 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require File.join(File.dirname(__FILE__), 'spec_helper')
+
+describe 'Server' do
+ include Caldecott::Test::Server
+
+ before do
+ @log = Logger.new StringIO.new
+ @host = "foobar"
+ @port = 4242
+ @max_data_to_buffer = 40000
+
+ @start_time = Time.now
+ @tunnels = Caldecott::Server::HttpTunnel.tunnels
+ @tunnel = Caldecott::Server::Tunnel.new(@log, @tunnels, @host, @port, @max_data_to_buffer)
+
+ # FIXME: we need to be able to shut up the logger directly
+ Caldecott::SessionLogger.stub(:new).and_return(Logger.new StringIO.new)
+ end
+
+ after do
+ @connection.should_receive(:close_connection_after_writing).at_most(:once) { @onclose.call } if @connection
+ @tunnel.delete
+ end
+
+ describe 'HTTP Tunnel' do
+ describe "when not connected yet" do
+ it "should not add itself to the active tunnels" do
+ @tunnels.length.should == 0
+ end
+
+ it "should have an initial activity time" do
+ @tunnel.last_active_at.should > @start_time
+ end
+ end
+
+ describe "when connected" do
+ before do
+ simulate_tunnel_open
+ end
+
+ it "should return connection info" do
+ validate_tunnel_info(@tunnel_info, @host, @port)
+ end
+
+ it "should add itself to the active tunnels" do
+ @tunnels.length.should == 1
+ end
+
+ it "should be removed from active tunnels when inactive" do
+ EM.run do
+ @tunnels.length.should == 1
+ @connection.should_receive(:close_connection_after_writing).at_most(:once) { @onclose.call }
+
+ # 3 seconds inactivity, 1 seconds sweeps
+ Caldecott::Server::HttpTunnel.start_timer(2, 1)
+
+ EM.add_timer(3) do
+ @tunnels.length.should == 0
+ EM.stop
+ end
+ end
+ end
+
+ describe "#get" do
+ it "should return an error when asked to GET data for sequence < current_sequence" do
+ do_with_invalid_sequence :get, -1
+ do_with_invalid_sequence :get, -2
+ do_with_invalid_sequence :get, -10
+ end
+
+ it "should return an error when asked to GET data for sequence > current_sequence + 1" do
+ do_with_invalid_sequence :get, 2
+ do_with_invalid_sequence :get, 3
+ do_with_invalid_sequence :get, 10
+ end
+
+ it "should GET data asynchronously and synchronously" do
+ data = "this is some data"
+
+ # do a read before data has been received from the destination
+ response = mock(Sinatra::Base)
+ @tunnel.get(response, @tunnel_info[:seq_out])
+
+ # now simulate data from the destionation
+ response.should_receive(:body).with(data)
+ @onreceive.call(data)
+
+ # re-read the data. Since we didn't advance the sequence number,
+ # we should get the same data back. It should return right away.
+ response2 = mock(Sinatra::Base)
+ response2.should_receive(:body).with(data)
+ @connection.should_receive(:resume).at_most(:once)
+ @tunnel.get(response2, @tunnel_info[:seq_out])
+ end
+
+ it "should advance the GET sequence numbers by 1" do
+ data1 = "this is some data"
+ data2 = "even more data!"
+ sequence = @tunnel_info[:seq_out]
+
+ @onreceive.call(data1)
+
+ # read from the current sequence number
+ response = mock(Sinatra::Base)
+ response.should_receive(:body).with(data1)
+ @connection.should_receive(:resume).at_most(:once)
+ @tunnel.get(response, sequence += 1)
+
+ @onreceive.call(data2)
+
+ # read from the next seqence number
+ response2 = mock(Sinatra::Base)
+ response2.should_receive(:body).with(data2)
+ @connection.should_receive(:resume).at_most(:once)
+ @tunnel.get(response2, sequence += 1)
+ end
+
+ it "should provide flow control" do
+ sequence = @tunnel_info[:seq_out]
+ data = 'A' * (@max_data_to_buffer - 2)
+ @onreceive.call(data)
+
+ # should not trigger flow controll still
+ @onreceive.call("ab")
+
+ # this one should (we are 1 character over)
+ @connection.should_receive(:pause)
+ @onreceive.call("c")
+
+ # read 1 byte. flow controll should not get turned off
+ response = mock(Sinatra::Base)
+ response.should_receive(:body).with(data + "abc")
+ @connection.should_receive(:resume)
+ @tunnel.get(response, sequence += 1)
+ end
+
+ end
+
+ describe "#put" do
+ it "should return an error when asked to PUT data for sequence < current_sequence" do
+ do_with_invalid_sequence :put, -1
+ do_with_invalid_sequence :put, -2
+ do_with_invalid_sequence :put, -10
+ end
+
+ it "should return an error when asked to PUT data for sequence > current_sequence + 1" do
+ do_with_invalid_sequence :put, 2
+ do_with_invalid_sequence :put, 3
+ do_with_invalid_sequence :put, 10
+ end
+
+ it "should be idempotent" do
+ response = mock(Sinatra::Base)
+ response.should_receive(:status).with(201)
+ @tunnel.put(response, @tunnel_info[:seq_in])
+ end
+
+ it "should advance the PUT sequence numbers by 1" do
+ sequence = @tunnel_info[:seq_out]
+
+ ["first data", "second data"].each do |data|
+ request_body = StringIO.new data
+
+ request = mock(Sinatra::Base)
+ request.should_receive(:body).at_least(:once).and_return(request_body)
+
+ response = mock(Sinatra::Base)
+ response.should_receive(:request).at_least(:once).and_return(request)
+ response.should_receive(:status).with(202)
+
+ @connection.should_receive(:send_data).with(data)
+ @tunnel.put(response, sequence += 1)
+ end
+ end
+ end
+
+ describe "#delete" do
+ it "should remote itself from the active tunnels" do
+ @tunnels.length.should == 1
+ @connection.should_receive(:close_connection_after_writing) { @onclose.call }
+ @tunnel.delete
+ @tunnels.length.should == 0
+ end
+ end
+
+ describe "and then disconnected" do
+ describe "#get" do
+ it "should return an error when the destination closes while waiting for data" do
+ # do a read before data has been received from the destination
+ response = mock(Sinatra::Base)
+ @tunnel.get(response, @tunnel_info[:seq_out])
+ response.should_receive(:ahalt).with(410, instance_of(String)).and_raise
+ lambda { @onclose.call }.should raise_exception
+ end
+ end
+
+ describe "#delete" do
+ it "should remove itself from the active tunnels" do
+ @tunnels.length.should == 1
+ @connection.should_receive(:close_connection_after_writing).at_most(:once) { @onclose.call }
+ @tunnel.delete
+ @tunnels.length.should == 0
+ end
+ end
+ end
+ end
+ end
+
+ describe "Sinatra endpoint" do
+ include Test::Unit::Assertions
+ include Rack::Test::Methods
+ include Sinatra::Async::Test::Methods
+ include Caldecott::Test::Server::SinatraTest
+
+ it "should return banner via GET" do
+ get '/'
+ last_response.should be_ok
+ last_response.body.should == "Caldecott Tunnel (HTTP Transport) #{Caldecott::VERSION}\n"
+ end
+
+ it "should respond to GET /tunnels" do
+ get '/tunnels'
+ last_response.should be_ok
+ response = JSON.parse(last_response.body, :symbolize_names => true)
+ response.length.should == 0
+ end
+
+ it "should forward POSTs to Tunnel" do
+ Caldecott::Server::Tunnel.should_receive(:new).once.with(duck_type(:debug), @tunnels, @host, @port).and_return(@tunnel)
+ simulate_connection_open_for do
+ apost '/tunnels', { :host => @host, :port => @port }.to_json
+ end
+ em_async_continue
+ end
+
+ describe 'tunnel operations' do
+ before do
+ simulate_tunnel_open
+ end
+
+ it "should include the tunnel in GET /tunnels" do
+ get '/tunnels'
+ last_response.should be_ok
+ response = JSON.parse(last_response.body, :symbolize_names => true)
+ response.length.should == 1
+ validate_tunnel_info(response[0], @host, @port)
+ end
+
+ it "should repond to GET /tunnel/:valid_id" do
+ get @tunnel_info[:path]
+ last_response.should be_ok
+ response = JSON.parse(last_response.body, :symbolize_names => true)
+ validate_tunnel_info(response, @host, @port)
+ end
+
+ it "should return a 404 for GET /tunnel/:invalid_id" do
+ get "#{@tunnel_info[:path] + "nope"}"
+ last_response.status.should == 404
+ end
+
+ it "should return a 400 for GET /tunnel (no id)" do
+ get '/tunnels/'
+ # FIXME: validate some reasonable error here rather then the stock
+ # sinatra error
+ last_response.status.should == 404
+ end
+
+ it "should forward a DELETE to the tunnel" do
+ @connection.should_receive(:close_connection_after_writing).at_most(:once) { @onclose.call }
+ delete @tunnel_info[:path]
+ @tunnel.should_receive(:delete)
+ end
+
+ it "should return a 404 for DELETE /tunnel/:invalid_id" do
+ delete "#{@tunnel_info[:path] + "nope"}"
+ last_response.status.should == 404
+ end
+
+ it "should forward a PUT to the tunnel" do
+ sequence = 502
+ @tunnel.should_receive(:put).once.with(duck_type(:request), sequence)
+ put "#{@tunnel_info[:path_in]}/#{sequence}", "some data"
+ end
+
+ it "should return a 404 for a PUT to an invalid tunnel" do
+ put "#{@tunnel_info[:path_in] + "nope"}/#{@tunnel_info[:seq_in] + 1}", "data"
+ last_response.status.should == 404
+ end
+
+ it "should forward a GET request to the tunnel" do
+ sequence = 692
+ @tunnel.should_receive(:get).once.with(duck_type(:response), sequence) { |response, sequence| response.body "data" }
+ aget "#{@tunnel_info[:path_out]}/#{sequence}"
+ em_async_continue
+ end
+
+ it "should return a 404 for a GET to an invalid tunnel" do
+ # The get should return right away. The async sinatra unit test
+ # methods don't really allow for that use case and they throw an
+ # exception, however, the response does come back and can be checked.
+ lambda { aget "#{@tunnel_info[:path_out] + "nope"}/#{@tunnel_info[:seq_out] + 1}"}.should raise_error
+ last_response.status.should == 404
+ end
+ end
+ end
+end
View
68 spec/server/spec_helper.rb
@@ -0,0 +1,68 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require File.join(File.dirname(__FILE__), '..', 'spec_helper')
+
+require 'test/unit'
+require 'sinatra'
+require 'sinatra/async/test'
+
+require 'caldecott/server/http_tunnel.rb'
+require 'caldecott/tcp_connection.rb'
+
+module Caldecott
+ module Test
+ module Server
+ def validate_tunnel_info(tunnel_info, host, port)
+ [:path, :path_in, :path_out].each do |k|
+ tunnel_info[k].should_not be_nil
+ tunnel_info[k].length.should > 0
+ end
+
+ tunnel_info[:dst_host].should == host
+ tunnel_info[:dst_port].should == port
+ tunnel_info[:dst_connected].should == true
+ tunnel_info[:seq_out].should >= 0
+ tunnel_info[:seq_in].should >= 0
+ end
+
+ def simulate_tunnel_open
+ request = mock(::Sinatra::Base)
+ request.should_receive(:content_type).with(:json)
+ request.should_receive(:status).with(201)
+ request.should_receive(:body) { |body| @tunnel_info = JSON.parse(body, :symbolize_names => true) }
+ simulate_connection_open_for { @tunnel.open(request) }
+ end
+
+ def simulate_connection_open_for
+ @connection = mock(Caldecott::TcpConnection)
+ @connection.should_receive(:onopen) { |*args, &blk| @onopen = blk }
+ @connection.should_receive(:onreceive) { |*args, &blk| @onreceive = blk }
+ @connection.should_receive(:onclose) { |*args, &blk| @onclose = blk }
+
+ EM.should_receive(:connect).once.with(@host, @port, Caldecott::TcpConnection).and_yield(@connection)
+ yield
+ @onopen.call
+ end
+
+ def do_with_invalid_sequence(method, offset)
+ response = mock(::Sinatra::Base)
+ response.should_receive(:halt).with(400, instance_of(String)).and_raise
+ lambda { @tunnel.send(method, response, @tunnel_info[:seq_out] + offset) }.should raise_exception
+ end
+
+ module SinatraTest
+ class App < Caldecott::Server::HttpTunnel
+ set :environment, :test
+ def self.options
+ self.settings
+ end
+ end
+
+ def app
+ App
+ end
+ end
+
+ end
+ end
+end
View
70 spec/server/websocket_tunnel_spec.rb
@@ -0,0 +1,70 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require File.join(File.dirname(__FILE__), 'spec_helper')
+require 'caldecott/server/websocket_tunnel.rb'
+
+describe 'Server Websocket' do
+ describe 'Tunnel' do
+ it 'should have the same run! interface as sinatra' do
+ port = 4242
+ tunnel = mock(Caldecott::Server::WebSocketTunnel)
+ tunnel.should_receive(:start).with(port).and_return(tunnel)
+ Caldecott::Server::WebSocketTunnel.should_receive(:new).and_return(tunnel)
+ Caldecott::Server::WebSocketTunnel.run!(:port => port)
+ end
+ end
+
+ describe 'Websocket interface' do
+ before do
+ @host = 'foobar'
+ @port = 50000
+
+ # FIXME: we need to be able to shut up the logger directly
+ Caldecott::SessionLogger.stub(:new).and_return(Logger.new StringIO.new)
+
+ @websocket = mock(EM::WebSocket)
+ @websocket.should_receive(:onopen) { |*args, &blk| @websocket_onopen = blk }
+ @websocket.should_receive(:onmessage) { |*args, &blk| @websocket_onmessage = blk }
+ @websocket.should_receive(:onclose) { |*args, &blk| @websocket_onclose = blk }
+ @websocket.should_receive(:request).and_return('Path' => "/websocket/#{@host}/#{@port}")
+
+ websocket_port = 40000
+ EM::WebSocket.should_receive(:start).with(:host => '0.0.0.0', :port => websocket_port).and_yield(@websocket)
+
+ @tunnel = Caldecott::Server::WebSocketTunnel.new
+ @tunnel.start(websocket_port)
+
+ @connection = mock(Caldecott::TcpConnection)
+ @connection.should_receive(:onopen) { |*args, &blk| @connection_onopen = blk }
+ @connection.should_receive(:onreceive) { |*args, &blk| @connection_onreceive = blk }
+ @connection.should_receive(:onclose) { |*args, &blk| @connection_onclose = blk }
+
+ EM.should_receive(:connect).once.with(@host, @port.to_s, Caldecott::TcpConnection).and_yield(@connection)
+
+ @websocket_onopen.call
+ @connection_onopen.call
+ end
+
+ it 'should send data to the websocket that it receives from the destination' do
+ data = "this is some data from the destination"
+ @websocket.should_receive(:send).with(Base64.encode64(data))
+ @connection_onreceive.call(data)
+ end
+
+ it 'should send data to the destination that it receives from the websocket' do
+ data = "this is some data from the client"
+ @connection.should_receive(:send_data).with(data)
+ @websocket_onmessage.call(Base64.encode64(data))
+ end
+
+ it 'should close the websocket when the destination socket closes' do
+ @websocket.should_receive(:close_connection)
+ @connection_onclose.call
+ end
+
+ it 'should close the destination socket when the websocket closes' do
+ @connection.should_receive(:close_connection_after_writing)
+ @websocket_onclose.call
+ end
+ end
+end
View
67 spec/session_logger_spec.rb
@@ -0,0 +1,67 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require File.join(File.dirname(__FILE__), 'spec_helper')
+require 'caldecott/session_logger.rb'
+
+describe 'session logger' do
+ describe '#initialize' do
+ it 'should generate unique session identifiers' do
+ component = "blabla"
+ logger1 = Caldecott::SessionLogger.new(component, StringIO.new)
+ logger2 = Caldecott::SessionLogger.new(component, StringIO.new)
+
+ logger1.component.should == component
+ logger2.component.should == component
+
+ logger1.session.should_not == logger2.session
+ end
+ end
+
+ describe '#format_message' do
+ it 'should include the component and session id' do
+ component = "blabla"
+ logger = Caldecott::SessionLogger.new(component, StringIO.new)
+
+ message = "ipsum lorem"
+ result = logger.format_message(Logger::DEBUG, Time.now, "test", message)
+ result.should match /#{component}/
+ result.should match /#{message}/
+ result.should match /#{logger.session.to_s}/
+ end
+ end
+
+ describe '#severity from string' do
+ def validate_parsing(str, level)
+ Caldecott::SessionLogger.severity_from_string(str.upcase).should == level
+ Caldecott::SessionLogger.severity_from_string(str.downcase).should == level
+ end
+
+ it 'should parse DEBUG' do
+ validate_parsing 'debug', Logger::DEBUG
+ end
+
+ it 'should parse INFO' do
+ validate_parsing 'info', Logger::INFO
+ end
+
+ it 'should parse WARN' do
+ validate_parsing 'warn', Logger::WARN
+ end
+
+ it 'should parse ERROR' do
+ validate_parsing 'error', Logger::ERROR
+ end
+
+ it 'should parse FATAL' do
+ validate_parsing 'fatal', Logger::FATAL
+ end
+
+ it 'should parse UNKNOWN' do
+ validate_parsing 'unknown', Logger::UNKNOWN
+ end
+
+ it 'should parase bad in put as ERROR' do
+ validate_parsing 'blabla', Logger::ERROR
+ end
+ end
+end
View
25 spec/spec_helper.rb
@@ -0,0 +1,25 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+$:.unshift File.join(File.dirname(__FILE__), '..')
+$:.unshift File.join(File.dirname(__FILE__), '..', 'lib')
+
+home = File.join(File.dirname(__FILE__), '/..')
+ENV['BUNDLE_GEMFILE'] = "#{home}/Gemfile"
+
+require 'bundler'
+require 'bundler/setup'
+require 'rubygems'
+require 'rspec'
+
+module Caldecott
+ module Test
+ def with_em_timeout(timeout = 2)
+ EM.run do
+ EM.add_timer(timeout) do
+ @validate.call if @validate
+ EM.stop
+ end
+ yield
+ end
+ end
+ end
+end
View
52 spec/tcp_connection_spec.rb
@@ -0,0 +1,52 @@
+# Copyright (c) 2009-2011 VMware, Inc.
+
+require File.join(File.dirname(__FILE__), 'spec_helper')
+require 'caldecott/tcp_connection.rb'
+
+describe 'tcp connection' do
+ before do
+ @times_called = { :onopen => 0, :onreceive => 0, :onclose => 0 }
+ @expected_times_called = { :onopen => 1, :onreceive => 0, :onclose => 0 }
+
+ @conn = Caldecott::TcpConnection.new nil
+ @conn.onopen { @times_called[:onopen] += 1 }
+ @conn.onreceive { @times_called[:onreceive] += 1 }
+ @conn.onclose { @times_called[:onclose] += 1 }
+
+ @conn.post_init
+ end
+
+ def validate_times_called
+ @times_called[:onopen].should == @expected_times_called[:onopen]
+ @times_called[:onreceive].should == @expected_times_called[:onreceive]
+ @times_called[:onclose].should == @expected_times_called[:onclose]
+ end
+
+ describe 'callbacks setup before the connection is established' do
+ it 'should call onopen after the connection is initialized' do
+ # we can't test sequence too explicitly.. currently EM calls post_init
+ # right when the Connection#new is called, but that might not always
+ # be the behavior. We'll call it ourselves just to make sure it gets
+ # called though. (The implementation only calls the provided block in
+ # either case.)
+ validate_times_called
+ end
+
+ it 'should call receive_data when data is received' do
+ validate_times_called
+ @conn.receive_data "data"
+ @expected_times_called[:onreceive] = 1
+ validate_times_called
+ @conn.receive_data "more data"
+ @expected_times_called[:onreceive] = 2
+ validate_times_called
+ end
+
+ it 'should call onclose when the connection is closed' do
+ validate_times_called
+ @conn.unbind
+ @expected_times_called[:onclose] = 1
+ validate_times_called
+ end
+ end
+end

0 comments on commit 4242c80

Please sign in to comment.